diff options
Diffstat (limited to 'tests/managers/thl/test_ledger')
| -rw-r--r-- | tests/managers/thl/test_ledger/__init__.py | 0 | ||||
| -rw-r--r-- | tests/managers/thl/test_ledger/test_lm_accounts.py | 268 | ||||
| -rw-r--r-- | tests/managers/thl/test_ledger/test_lm_tx.py | 235 | ||||
| -rw-r--r-- | tests/managers/thl/test_ledger/test_lm_tx_entries.py | 26 | ||||
| -rw-r--r-- | tests/managers/thl/test_ledger/test_lm_tx_locks.py | 371 | ||||
| -rw-r--r-- | tests/managers/thl/test_ledger/test_lm_tx_metadata.py | 34 | ||||
| -rw-r--r-- | tests/managers/thl/test_ledger/test_thl_lm_accounts.py | 411 | ||||
| -rw-r--r-- | tests/managers/thl/test_ledger/test_thl_lm_bp_payout.py | 516 | ||||
| -rw-r--r-- | tests/managers/thl/test_ledger/test_thl_lm_tx.py | 1762 | ||||
| -rw-r--r-- | tests/managers/thl/test_ledger/test_thl_lm_tx__user_payouts.py | 505 | ||||
| -rw-r--r-- | tests/managers/thl/test_ledger/test_thl_pem.py | 251 | ||||
| -rw-r--r-- | tests/managers/thl/test_ledger/test_user_txs.py | 288 | ||||
| -rw-r--r-- | tests/managers/thl/test_ledger/test_wallet.py | 78 |
13 files changed, 4745 insertions, 0 deletions
diff --git a/tests/managers/thl/test_ledger/__init__.py b/tests/managers/thl/test_ledger/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/managers/thl/test_ledger/__init__.py diff --git a/tests/managers/thl/test_ledger/test_lm_accounts.py b/tests/managers/thl/test_ledger/test_lm_accounts.py new file mode 100644 index 0000000..e0d9b0b --- /dev/null +++ b/tests/managers/thl/test_ledger/test_lm_accounts.py @@ -0,0 +1,268 @@ +from itertools import product as iproduct +from random import randint +from uuid import uuid4 + +import pytest + +from generalresearch.currency import LedgerCurrency +from generalresearch.managers.base import Permission +from generalresearch.managers.thl.ledger_manager.exceptions import ( + LedgerAccountDoesntExistError, +) +from generalresearch.managers.thl.ledger_manager.ledger import LedgerManager +from generalresearch.models.thl.ledger import LedgerAccount, AccountType, Direction +from generalresearch.models.thl.ledger import ( + LedgerEntry, +) +from test_utils.managers.ledger.conftest import ledger_account + + +@pytest.mark.parametrize( + argnames="currency, kind, acct_id", + argvalues=list( + iproduct( + ["USD", "test", "EUR"], + ["expense", "wallet", "revenue", "cash"], + [uuid4().hex for i in range(3)], + ) + ), +) +class TestLedgerAccountManagerNoResults: + + def test_get_account_no_results(self, currency, kind, acct_id, lm): + """Try to query for accounts that we know don't exist and confirm that + we either get the expected None result or it raises the correct + exception + """ + qn = ":".join([currency, kind, acct_id]) + + # (1) .get_account is just a wrapper for .get_account_many_ but + # call it either way + assert lm.get_account(qualified_name=qn, raise_on_error=False) is None + + with pytest.raises(expected_exception=LedgerAccountDoesntExistError): + lm.get_account(qualified_name=qn, raise_on_error=True) + + # (2) .get_account_if_exists is another wrapper + assert lm.get_account(qualified_name=qn, raise_on_error=False) is None + + def test_get_account_no_results_many(self, currency, kind, acct_id, lm): + qn = ":".join([currency, kind, acct_id]) + + # (1) .get_many_ + assert lm.get_account_many_(qualified_names=[qn], raise_on_error=False) == [] + + with pytest.raises(expected_exception=LedgerAccountDoesntExistError): + lm.get_account_many_(qualified_names=[qn], raise_on_error=True) + + # (2) .get_many + assert lm.get_account_many(qualified_names=[qn], raise_on_error=False) == [] + + with pytest.raises(expected_exception=LedgerAccountDoesntExistError): + lm.get_account_many(qualified_names=[qn], raise_on_error=True) + + # (3) .get_accounts(..) + assert lm.get_accounts_if_exists(qualified_names=[qn]) == [] + + with pytest.raises(expected_exception=LedgerAccountDoesntExistError): + lm.get_accounts(qualified_names=[qn]) + + +@pytest.mark.parametrize( + argnames="currency, account_type, direction", + argvalues=list( + iproduct( + list(LedgerCurrency), + list(AccountType), + list(Direction), + ) + ), +) +class TestLedgerAccountManagerCreate: + + def test_create_account_error_permission( + self, currency, account_type, direction, lm + ): + """Confirm that the Permission values that are set on the Ledger Manger + allow the Creation action to occur. + """ + acct_uuid = uuid4().hex + + account = LedgerAccount( + display_name=f"test-{uuid4().hex}", + currency=currency, + qualified_name=f"{currency.value}:{account_type.value}:{acct_uuid}", + account_type=account_type, + normal_balance=direction, + ) + + # (1) With no Permissions defined + test_lm = LedgerManager( + pg_config=lm.pg_config, + permissions=[], + redis_config=lm.redis_config, + cache_prefix=lm.cache_prefix, + testing=lm.testing, + ) + + with pytest.raises(expected_exception=AssertionError) as excinfo: + test_lm.create_account(account=account) + assert ( + str(excinfo.value) == "LedgerManager does not have sufficient permissions" + ) + + # (2) With Permissions defined, but not CREATE + test_lm = LedgerManager( + pg_config=lm.pg_config, + permissions=[Permission.READ, Permission.UPDATE, Permission.DELETE], + redis_config=lm.redis_config, + cache_prefix=lm.cache_prefix, + testing=lm.testing, + ) + + with pytest.raises(expected_exception=AssertionError) as excinfo: + test_lm.create_account(account=account) + assert ( + str(excinfo.value) == "LedgerManager does not have sufficient permissions" + ) + + def test_create(self, currency, account_type, direction, lm): + """Confirm that the Permission values that are set on the Ledger Manger + allow the Creation action to occur. + """ + + acct_uuid = uuid4().hex + qn = f"{currency.value}:{account_type.value}:{acct_uuid}" + + acct_model = LedgerAccount( + uuid=acct_uuid, + display_name=f"test-{uuid4().hex}", + currency=currency, + qualified_name=qn, + account_type=account_type, + normal_balance=direction, + ) + account = lm.create_account(account=acct_model) + assert isinstance(account, LedgerAccount) + + # Query for, and make sure the Account was saved in the DB + res = lm.get_account(qualified_name=qn, raise_on_error=True) + assert account.uuid == res.uuid + + def test_get_or_create(self, currency, account_type, direction, lm): + """Confirm that the Permission values that are set on the Ledger Manger + allow the Creation action to occur. + """ + + acct_uuid = uuid4().hex + qn = f"{currency.value}:{account_type.value}:{acct_uuid}" + + acct_model = LedgerAccount( + uuid=acct_uuid, + display_name=f"test-{uuid4().hex}", + currency=currency, + qualified_name=qn, + account_type=account_type, + normal_balance=direction, + ) + account = lm.get_account_or_create(account=acct_model) + assert isinstance(account, LedgerAccount) + + # Query for, and make sure the Account was saved in the DB + res = lm.get_account(qualified_name=qn, raise_on_error=True) + assert account.uuid == res.uuid + + +class TestLedgerAccountManagerGet: + + def test_get(self, ledger_account, lm): + res = lm.get_account(qualified_name=ledger_account.qualified_name) + assert res.uuid == ledger_account.uuid + + res = lm.get_account_many(qualified_names=[ledger_account.qualified_name]) + assert len(res) == 1 + assert res[0].uuid == ledger_account.uuid + + res = lm.get_accounts(qualified_names=[ledger_account.qualified_name]) + assert len(res) == 1 + assert res[0].uuid == ledger_account.uuid + + # TODO: I can't test the get_balance without first having Transaction + # creation working + + def test_get_balance_empty( + self, ledger_account, ledger_account_credit, ledger_account_debit, ledger_tx, lm + ): + res = lm.get_account_balance(account=ledger_account) + assert res == 0 + + res = lm.get_account_balance(account=ledger_account_credit) + assert res == 100 + + res = lm.get_account_balance(account=ledger_account_debit) + assert res == 100 + + @pytest.mark.parametrize("n_times", range(5)) + def test_get_account_filtered_balance( + self, + ledger_account, + ledger_account_credit, + ledger_account_debit, + ledger_tx, + n_times, + lm, + ): + """Try searching for random metadata and confirm it's always 0 because + Tx can be found. + """ + rand_key = f"key-{uuid4().hex[:10]}" + rand_value = uuid4().hex + + assert ( + lm.get_account_filtered_balance( + account=ledger_account, metadata_key=rand_key, metadata_value=rand_value + ) + == 0 + ) + + # Let's create a transaction with this metadata to confirm it saves + # and that we can filter it back + rand_amount = randint(10, 1_000) + + lm.create_tx( + entries=[ + LedgerEntry( + direction=Direction.CREDIT, + account_uuid=ledger_account_credit.uuid, + amount=rand_amount, + ), + LedgerEntry( + direction=Direction.DEBIT, + account_uuid=ledger_account_debit.uuid, + amount=rand_amount, + ), + ], + metadata={rand_key: rand_value}, + ) + + assert ( + lm.get_account_filtered_balance( + account=ledger_account_credit, + metadata_key=rand_key, + metadata_value=rand_value, + ) + == rand_amount + ) + + assert ( + lm.get_account_filtered_balance( + account=ledger_account_debit, + metadata_key=rand_key, + metadata_value=rand_value, + ) + == rand_amount + ) + + def test_get_balance_timerange_empty(self, ledger_account, lm): + res = lm.get_account_balance_timerange(account=ledger_account) + assert res == 0 diff --git a/tests/managers/thl/test_ledger/test_lm_tx.py b/tests/managers/thl/test_ledger/test_lm_tx.py new file mode 100644 index 0000000..37b7ba3 --- /dev/null +++ b/tests/managers/thl/test_ledger/test_lm_tx.py @@ -0,0 +1,235 @@ +from decimal import Decimal +from random import randint +from uuid import uuid4 + +import pytest + +from generalresearch.currency import LedgerCurrency +from generalresearch.managers.thl.ledger_manager.ledger import LedgerManager +from generalresearch.models.thl.ledger import ( + Direction, + LedgerEntry, + LedgerTransaction, +) + + +class TestLedgerManagerCreateTx: + + def test_create_account_error_permission(self, lm): + """Confirm that the Permission values that are set on the Ledger Manger + allow the Creation action to occur. + """ + acct_uuid = uuid4().hex + + # (1) With no Permissions defined + test_lm = LedgerManager( + pg_config=lm.pg_config, + permissions=[], + redis_config=lm.redis_config, + cache_prefix=lm.cache_prefix, + testing=lm.testing, + ) + + with pytest.raises(expected_exception=AssertionError) as excinfo: + test_lm.create_tx(entries=[]) + assert ( + str(excinfo.value) + == "LedgerTransactionManager has insufficient Permissions" + ) + + def test_create_assertions(self, ledger_account_debit, ledger_account_credit, lm): + with pytest.raises(expected_exception=ValueError) as excinfo: + lm.create_tx( + entries=[ + { + "direction": Direction.CREDIT, + "account_uuid": uuid4().hex, + "amount": randint(a=1, b=100), + } + ] + ) + assert ( + "Assertion failed, ledger transaction must have 2 or more entries" + in str(excinfo.value) + ) + + def test_create(self, ledger_account_credit, ledger_account_debit, lm): + amount = int(Decimal("1.00") * 100) + + entries = [ + LedgerEntry( + direction=Direction.CREDIT, + account_uuid=ledger_account_credit.uuid, + amount=amount, + ), + LedgerEntry( + direction=Direction.DEBIT, + account_uuid=ledger_account_debit.uuid, + amount=amount, + ), + ] + + # Create a Transaction and validate the operation was successful + tx = lm.create_tx(entries=entries) + assert isinstance(tx, LedgerTransaction) + + res = lm.get_tx_by_id(transaction_id=tx.id) + assert isinstance(res, LedgerTransaction) + assert len(res.entries) == 2 + assert tx.id == res.id + + def test_create_and_reverse(self, ledger_account_credit, ledger_account_debit, lm): + amount = int(Decimal("1.00") * 100) + + entries = [ + LedgerEntry( + direction=Direction.CREDIT, + account_uuid=ledger_account_credit.uuid, + amount=amount, + ), + LedgerEntry( + direction=Direction.DEBIT, + account_uuid=ledger_account_debit.uuid, + amount=amount, + ), + ] + + tx = lm.create_tx(entries=entries) + res = lm.get_tx_by_id(transaction_id=tx.id) + assert res.id == tx.id + + assert lm.get_account_balance(account=ledger_account_credit) == 100 + assert lm.get_account_balance(account=ledger_account_debit) == 100 + assert lm.check_ledger_balanced() is True + + # Reverse it + entries = [ + LedgerEntry( + direction=Direction.DEBIT, + account_uuid=ledger_account_credit.uuid, + amount=amount, + ), + LedgerEntry( + direction=Direction.CREDIT, + account_uuid=ledger_account_debit.uuid, + amount=amount, + ), + ] + + tx = lm.create_tx(entries=entries) + res = lm.get_tx_by_id(transaction_id=tx.id) + assert res.id == tx.id + + assert lm.get_account_balance(ledger_account_credit) == 0 + assert lm.get_account_balance(ledger_account_debit) == 0 + assert lm.check_ledger_balanced() + + # subtract again + entries = [ + LedgerEntry( + direction=Direction.DEBIT, + account_uuid=ledger_account_credit.uuid, + amount=amount, + ), + LedgerEntry( + direction=Direction.CREDIT, + account_uuid=ledger_account_debit.uuid, + amount=amount, + ), + ] + tx = lm.create_tx(entries=entries) + res = lm.get_tx_by_id(transaction_id=tx.id) + assert res.id == tx.id + + assert lm.get_account_balance(ledger_account_credit) == -100 + assert lm.get_account_balance(ledger_account_debit) == -100 + assert lm.check_ledger_balanced() + + +class TestLedgerManagerGetTx: + + # @pytest.mark.parametrize("currency", [LedgerCurrency.TEST], indirect=True) + def test_get_tx_by_id(self, ledger_tx, lm): + with pytest.raises(expected_exception=AssertionError): + lm.get_tx_by_id(transaction_id=ledger_tx) + + res = lm.get_tx_by_id(transaction_id=ledger_tx.id) + assert res.id == ledger_tx.id + + # @pytest.mark.parametrize("currency", [LedgerCurrency.TEST], indirect=True) + def test_get_tx_by_ids(self, ledger_tx, lm): + res = lm.get_tx_by_id(transaction_id=ledger_tx.id) + assert res.id == ledger_tx.id + + @pytest.mark.parametrize( + "tag", [f"{LedgerCurrency.TEST}:{uuid4().hex}"], indirect=True + ) + def test_get_tx_ids_by_tag(self, ledger_tx, tag, lm): + # (1) search for a random tag + res = lm.get_tx_ids_by_tag(tag="aaa:bbb") + assert isinstance(res, set) + assert len(res) == 0 + + # (2) search for the tag that was used during ledger_transaction creation + res = lm.get_tx_ids_by_tag(tag=tag) + assert isinstance(res, set) + assert len(res) == 1 + + def test_get_tx_by_tag(self, ledger_tx, tag, lm): + # (1) search for a random tag + res = lm.get_tx_by_tag(tag="aaa:bbb") + assert isinstance(res, list) + assert len(res) == 0 + + # (2) search for the tag that was used during ledger_transaction creation + res = lm.get_tx_by_tag(tag=tag) + assert isinstance(res, list) + assert len(res) == 1 + + assert isinstance(res[0], LedgerTransaction) + assert ledger_tx.id == res[0].id + + def test_get_tx_filtered_by_account( + self, ledger_tx, ledger_account, ledger_account_debit, ledger_account_credit, lm + ): + # (1) Do basic assertion checks first + with pytest.raises(expected_exception=AssertionError) as excinfo: + lm.get_tx_filtered_by_account(account_uuid=ledger_account) + assert str(excinfo.value) == "account_uuid must be a str" + + # (2) This search doesn't return anything because this ledger account + # wasn't actually used in the entries for the ledger_transaction + res = lm.get_tx_filtered_by_account(account_uuid=ledger_account.uuid) + assert len(res) == 0 + + # (3) Either the credit or the debit example ledger_accounts wll work + # to find this transaction because they're both used in the entries + res = lm.get_tx_filtered_by_account(account_uuid=ledger_account_debit.uuid) + assert len(res) == 1 + assert res[0].id == ledger_tx.id + + res = lm.get_tx_filtered_by_account(account_uuid=ledger_account_credit.uuid) + assert len(res) == 1 + assert ledger_tx.id == res[0].id + + res2 = lm.get_tx_by_id(transaction_id=ledger_tx.id) + assert res2.model_dump_json() == res[0].model_dump_json() + + def test_filter_metadata(self, ledger_tx, tx_metadata, lm): + key, value = next(iter(tx_metadata.items())) + + # (1) Confirm a random key,value pair returns nothing + res = lm.get_tx_filtered_by_metadata( + metadata_key=f"key-{uuid4().hex[:10]}", metadata_value=uuid4().hex[:12] + ) + assert len(res) == 0 + + # (2) confirm a key,value pair return the correct results + res = lm.get_tx_filtered_by_metadata(metadata_key=key, metadata_value=value) + assert len(res) == 1 + + # assert 0 == THL_lm.get_filtered_account_balance(account2, "thl_wall", "ccc") + # assert 300 == THL_lm.get_filtered_account_balance(account1, "thl_wall", "aaa") + # assert 300 == THL_lm.get_filtered_account_balance(account2, "thl_wall", "aaa") + # assert 0 == THL_lm.get_filtered_account_balance(account3, "thl_wall", "ccc") + # assert THL_lm.check_ledger_balanced() diff --git a/tests/managers/thl/test_ledger/test_lm_tx_entries.py b/tests/managers/thl/test_ledger/test_lm_tx_entries.py new file mode 100644 index 0000000..5bf1c48 --- /dev/null +++ b/tests/managers/thl/test_ledger/test_lm_tx_entries.py @@ -0,0 +1,26 @@ +from generalresearch.models.thl.ledger import LedgerEntry + + +class TestLedgerEntryManager: + + def test_get_tx_entries_by_tx(self, ledger_tx, lm): + # First confirm the Ledger TX exists with 2 Entries + res = lm.get_tx_by_id(transaction_id=ledger_tx.id) + assert len(res.entries) == 2 + + tx_entries = lm.get_tx_entries_by_tx(transaction=ledger_tx) + assert len(tx_entries) == 2 + + assert res.entries == tx_entries + assert isinstance(tx_entries[0], LedgerEntry) + + def test_get_tx_entries_by_txs(self, ledger_tx, lm): + # First confirm the Ledger TX exists with 2 Entries + res = lm.get_tx_by_id(transaction_id=ledger_tx.id) + assert len(res.entries) == 2 + + tx_entries = lm.get_tx_entries_by_txs(transactions=[ledger_tx]) + assert len(tx_entries) == 2 + + assert res.entries == tx_entries + assert isinstance(tx_entries[0], LedgerEntry) diff --git a/tests/managers/thl/test_ledger/test_lm_tx_locks.py b/tests/managers/thl/test_ledger/test_lm_tx_locks.py new file mode 100644 index 0000000..df2611b --- /dev/null +++ b/tests/managers/thl/test_ledger/test_lm_tx_locks.py @@ -0,0 +1,371 @@ +import logging +from datetime import datetime, timezone, timedelta +from decimal import Decimal +from typing import Callable + +import pytest + +from generalresearch.managers.thl.ledger_manager.conditions import ( + generate_condition_mp_payment, +) +from generalresearch.managers.thl.ledger_manager.exceptions import ( + LedgerTransactionCreateLockError, + LedgerTransactionFlagAlreadyExistsError, + LedgerTransactionCreateError, +) +from generalresearch.models import Source +from generalresearch.models.thl.ledger import LedgerTransaction +from generalresearch.models.thl.session import ( + Wall, + Status, + StatusCode1, + Session, + WallAdjustedStatus, +) +from generalresearch.models.thl.user import User +from test_utils.models.conftest import user_factory, session, product_user_wallet_no + +logger = logging.getLogger("LedgerManager") + + +class TestLedgerLocks: + + def test_a( + self, + user_factory, + session_factory, + product_user_wallet_no, + create_main_accounts, + caplog, + thl_lm, + lm, + utc_hour_ago, + currency, + wall_factory, + delete_ledger_db, + ): + """ + TODO: This whole test is confusing a I don't really understand. + It needs to be better documented and explained what we want + it to do and evaluate... + """ + delete_ledger_db() + create_main_accounts() + + user: User = user_factory(product=product_user_wallet_no) + s1 = session_factory( + user=user, + wall_count=3, + wall_req_cpis=[Decimal("1.23"), Decimal("3.21"), Decimal("4")], + wall_statuses=[Status.COMPLETE, Status.COMPLETE, Status.COMPLETE], + ) + + # A User does a Wall Completion in Session=1 + w1 = s1.wall_events[0] + tx = thl_lm.create_tx_task_complete(wall=w1, user=user, created=w1.started) + assert isinstance(tx, LedgerTransaction) + + # A User does another Wall Completion in Session=1 + w2 = s1.wall_events[1] + tx = thl_lm.create_tx_task_complete(wall=w2, user=user, created=w2.started) + assert isinstance(tx, LedgerTransaction) + + # That first Wall Complete was "adjusted" to instead be marked + # as a Failure + w1.update( + adjusted_status=WallAdjustedStatus.ADJUSTED_TO_FAIL, + adjusted_cpi=0, + adjusted_timestamp=utc_hour_ago + timedelta(hours=1), + ) + tx = thl_lm.create_tx_task_adjustment(wall=w1, user=user) + assert isinstance(tx, LedgerTransaction) + + # A User does another! Wall Completion in Session=1; however, we + # don't create a transaction for it + w3 = s1.wall_events[2] + + # Make sure we clear any flags/locks first + lock_key = f"{currency.value}:thl_wall:{w3.uuid}" + lock_name = f"{lm.cache_prefix}:transaction_lock:{lock_key}" + flag_name = f"{lm.cache_prefix}:transaction_flag:{lock_key}" + lm.redis_client.delete(lock_name) + lm.redis_client.delete(flag_name) + + # Despite the + f1 = generate_condition_mp_payment(wall=w1) + f2 = generate_condition_mp_payment(wall=w2) + f3 = generate_condition_mp_payment(wall=w3) + assert f1(lm=lm) is False + assert f2(lm=lm) is False + assert f3(lm=lm) is True + + condition = f3 + create_tx_func = lambda: thl_lm.create_tx_task_complete_(wall=w3, user=user) + assert isinstance(create_tx_func, Callable) + assert f3(lm) is True + + lm.redis_client.delete(flag_name) + lm.redis_client.delete(lock_name) + + tx = thl_lm.create_tx_protected( + lock_key=lock_key, condition=condition, create_tx_func=create_tx_func + ) + assert f3(lm) is False + + # purposely hold the lock open + tx = None + lm.redis_client.set(lock_name, "1") + with caplog.at_level(logging.ERROR): + with pytest.raises(expected_exception=LedgerTransactionCreateLockError): + tx = thl_lm.create_tx_protected( + lock_key=lock_key, + condition=condition, + create_tx_func=create_tx_func, + ) + assert tx is None + assert "Unable to acquire lock within the time specified" in caplog.text + lm.redis_client.delete(lock_name) + + def test_locking( + self, + user_factory, + product_user_wallet_no, + create_main_accounts, + delete_ledger_db, + caplog, + thl_lm, + lm, + ): + delete_ledger_db() + create_main_accounts() + + now = datetime.now(timezone.utc) - timedelta(hours=1) + user: User = user_factory(product=product_user_wallet_no) + + # A User does a Wall complete on Session.id=1 and the transaction is + # logged to the ledger + wall1 = Wall( + user_id=user.user_id, + source=Source.DYNATA, + req_survey_id="xxx", + req_cpi=Decimal("1.23"), + session_id=1, + status=Status.COMPLETE, + status_code_1=StatusCode1.COMPLETE, + started=now, + finished=now + timedelta(seconds=1), + ) + thl_lm.create_tx_task_complete(wall=wall1, user=user, created=wall1.started) + + # A User does a Wall complete on Session.id=1 and the transaction is + # logged to the ledger + wall2 = Wall( + user_id=user.user_id, + source=Source.FULL_CIRCLE, + req_survey_id="yyy", + req_cpi=Decimal("3.21"), + session_id=1, + status=Status.COMPLETE, + status_code_1=StatusCode1.COMPLETE, + started=now, + finished=now + timedelta(seconds=1), + ) + thl_lm.create_tx_task_complete(wall=wall2, user=user, created=wall2.started) + + # An hour later, the first wall complete is adjusted to a Failure and + # it's tracked in the ledger + wall1.update( + adjusted_status=WallAdjustedStatus.ADJUSTED_TO_FAIL, + adjusted_cpi=0, + adjusted_timestamp=now + timedelta(hours=1), + ) + thl_lm.create_tx_task_adjustment(wall=wall1, user=user) + + # A User does a Wall complete on Session.id=1 and the transaction + # IS NOT logged to the ledger + wall3 = Wall( + user_id=user.user_id, + source=Source.DYNATA, + req_survey_id="xxx", + req_cpi=Decimal("4"), + session_id=1, + status=Status.COMPLETE, + status_code_1=StatusCode1.COMPLETE, + started=now, + finished=now + timedelta(seconds=1), + uuid="867a282d8b4d40d2a2093d75b802b629", + ) + + revenue_account = thl_lm.get_account_task_complete_revenue() + assert 0 == thl_lm.get_account_filtered_balance( + account=revenue_account, + metadata_key="thl_wall", + metadata_value=wall3.uuid, + ) + # Make sure we clear any flags/locks first + lock_key = f"test:thl_wall:{wall3.uuid}" + lock_name = f"{lm.cache_prefix}:transaction_lock:{lock_key}" + flag_name = f"{lm.cache_prefix}:transaction_flag:{lock_key}" + lm.redis_client.delete(lock_name) + lm.redis_client.delete(flag_name) + + # Purposely hold the lock open + lm.redis_client.set(name=lock_name, value="1") + with caplog.at_level(logging.DEBUG): + with pytest.raises(expected_exception=LedgerTransactionCreateLockError): + tx = thl_lm.create_tx_task_complete( + wall=wall3, user=user, created=wall3.started + ) + assert isinstance(tx, LedgerTransaction) + assert "Unable to acquire lock within the time specified" in caplog.text + + # Release the lock + lm.redis_client.delete(lock_name) + + # Set the redis flag to indicate it has been run + lm.redis_client.set(flag_name, "1") + # with self.assertLogs(logger=logger, level=logging.DEBUG) as cm2: + with pytest.raises(expected_exception=LedgerTransactionFlagAlreadyExistsError): + tx = thl_lm.create_tx_task_complete( + wall=wall3, user=user, created=wall3.started + ) + # self.assertIn("entered_lock: True, flag_set: True", cm2.output[0]) + + # Unset the flag + lm.redis_client.delete(flag_name) + + assert 0 == lm.get_account_filtered_balance( + account=revenue_account, + metadata_key="thl_wall", + metadata_value=wall3.uuid, + ) + + # Now actually run it + tx = thl_lm.create_tx_task_complete( + wall=wall3, user=user, created=wall3.started + ) + assert tx is not None + + # Run it again, should return None + # Confirm the Exception inheritance works + tx = None + with pytest.raises(expected_exception=LedgerTransactionCreateError): + tx = thl_lm.create_tx_task_complete( + wall=wall3, user=user, created=wall3.started + ) + assert tx is None + + # clear the redis flag, it should query the db + assert lm.redis_client.get(flag_name) is not None + lm.redis_client.delete(flag_name) + assert lm.redis_client.get(flag_name) is None + + with pytest.raises(expected_exception=LedgerTransactionCreateError): + tx = thl_lm.create_tx_task_complete( + wall=wall3, user=user, created=wall3.started + ) + + assert 400 == thl_lm.get_account_filtered_balance( + account=revenue_account, + metadata_key="thl_wall", + metadata_value=wall3.uuid, + ) + + def test_bp_payment_without_locks( + self, user_factory, product_user_wallet_no, create_main_accounts, thl_lm, lm + ): + user: User = user_factory(product=product_user_wallet_no) + wall1 = Wall( + user_id=user.user_id, + source=Source.SAGO, + req_survey_id="xxx", + req_cpi=Decimal("0.50"), + session_id=3, + status=Status.COMPLETE, + status_code_1=StatusCode1.COMPLETE, + started=datetime.now(timezone.utc), + finished=datetime.now(timezone.utc) + timedelta(seconds=1), + ) + + thl_lm.create_tx_task_complete(wall=wall1, user=user, created=wall1.started) + session = Session(started=wall1.started, user=user, wall_events=[wall1]) + status, status_code_1 = session.determine_session_status() + thl_net, commission_amount, bp_pay, user_pay = session.determine_payments() + session.update( + **{ + "status": status, + "status_code_1": status_code_1, + "finished": session.started + timedelta(minutes=10), + "payout": bp_pay, + "user_payout": user_pay, + } + ) + print(thl_net, commission_amount, bp_pay, user_pay) + + # Run it 3 times without any checks, and it gets made three times! + thl_lm.create_tx_bp_payment(session=session, created=wall1.started) + thl_lm.create_tx_bp_payment_(session=session, created=wall1.started) + thl_lm.create_tx_bp_payment_(session=session, created=wall1.started) + + bp_wallet = thl_lm.get_account_or_create_bp_wallet(product=user.product) + assert 48 * 3 == lm.get_account_balance(account=bp_wallet) + assert 48 * 3 == thl_lm.get_account_filtered_balance( + account=bp_wallet, metadata_key="thl_session", metadata_value=session.uuid + ) + assert lm.check_ledger_balanced() + + def test_bp_payment_with_locks( + self, user_factory, product_user_wallet_no, create_main_accounts, thl_lm, lm + ): + user: User = user_factory(product=product_user_wallet_no) + + wall1 = Wall( + user_id=user.user_id, + source=Source.SAGO, + req_survey_id="xxx", + req_cpi=Decimal("0.50"), + session_id=3, + status=Status.COMPLETE, + status_code_1=StatusCode1.COMPLETE, + started=datetime.now(timezone.utc), + finished=datetime.now(timezone.utc) + timedelta(seconds=1), + ) + + thl_lm.create_tx_task_complete(wall1, user, created=wall1.started) + session = Session(started=wall1.started, user=user, wall_events=[wall1]) + status, status_code_1 = session.determine_session_status() + thl_net, commission_amount, bp_pay, user_pay = session.determine_payments() + session.update( + **{ + "status": status, + "status_code_1": status_code_1, + "finished": session.started + timedelta(minutes=10), + "payout": bp_pay, + "user_payout": user_pay, + } + ) + print(thl_net, commission_amount, bp_pay, user_pay) + + # Make sure we clear any flags/locks first + lock_key = f"test:thl_wall:{wall1.uuid}" + lock_name = f"{lm.cache_prefix}:transaction_lock:{lock_key}" + flag_name = f"{lm.cache_prefix}:transaction_flag:{lock_key}" + lm.redis_client.delete(lock_name) + lm.redis_client.delete(flag_name) + + # Run it 3 times with check, and it gets made once! + thl_lm.create_tx_bp_payment(session=session, created=wall1.started) + with pytest.raises(expected_exception=LedgerTransactionCreateError): + thl_lm.create_tx_bp_payment(session=session, created=wall1.started) + + with pytest.raises(expected_exception=LedgerTransactionCreateError): + thl_lm.create_tx_bp_payment(session=session, created=wall1.started) + + bp_wallet = thl_lm.get_account_or_create_bp_wallet(product=user.product) + assert 48 == thl_lm.get_account_balance(bp_wallet) + assert 48 == thl_lm.get_account_filtered_balance( + account=bp_wallet, + metadata_key="thl_session", + metadata_value=session.uuid, + ) + assert lm.check_ledger_balanced() diff --git a/tests/managers/thl/test_ledger/test_lm_tx_metadata.py b/tests/managers/thl/test_ledger/test_lm_tx_metadata.py new file mode 100644 index 0000000..5d12633 --- /dev/null +++ b/tests/managers/thl/test_ledger/test_lm_tx_metadata.py @@ -0,0 +1,34 @@ +class TestLedgerMetadataManager: + + def test_get_tx_metadata_by_txs(self, ledger_tx, lm): + # First confirm the Ledger TX exists with 2 Entries + res = lm.get_tx_by_id(transaction_id=ledger_tx.id) + assert isinstance(res.metadata, dict) + + tx_metadatas = lm.get_tx_metadata_by_txs(transactions=[ledger_tx]) + assert isinstance(tx_metadatas, dict) + assert isinstance(tx_metadatas[ledger_tx.id], dict) + + assert res.metadata == tx_metadatas[ledger_tx.id] + + def test_get_tx_metadata_ids_by_tx(self, ledger_tx, lm): + # First confirm the Ledger TX exists with 2 Entries + res = lm.get_tx_by_id(transaction_id=ledger_tx.id) + tx_metadata_cnt = len(res.metadata.keys()) + + tx_metadata_ids = lm.get_tx_metadata_ids_by_tx(transaction=ledger_tx) + assert isinstance(tx_metadata_ids, set) + assert isinstance(list(tx_metadata_ids)[0], int) + + assert tx_metadata_cnt == len(tx_metadata_ids) + + def test_get_tx_metadata_ids_by_txs(self, ledger_tx, lm): + # First confirm the Ledger TX exists with 2 Entries + res = lm.get_tx_by_id(transaction_id=ledger_tx.id) + tx_metadata_cnt = len(res.metadata.keys()) + + tx_metadata_ids = lm.get_tx_metadata_ids_by_txs(transactions=[ledger_tx]) + assert isinstance(tx_metadata_ids, set) + assert isinstance(list(tx_metadata_ids)[0], int) + + assert tx_metadata_cnt == len(tx_metadata_ids) diff --git a/tests/managers/thl/test_ledger/test_thl_lm_accounts.py b/tests/managers/thl/test_ledger/test_thl_lm_accounts.py new file mode 100644 index 0000000..01d5fe1 --- /dev/null +++ b/tests/managers/thl/test_ledger/test_thl_lm_accounts.py @@ -0,0 +1,411 @@ +from uuid import uuid4 + +import pytest + + +class TestThlLedgerManagerAccounts: + + def test_get_account_or_create_user_wallet(self, user, thl_lm, lm): + from generalresearch.currency import LedgerCurrency + from generalresearch.models.thl.ledger import ( + LedgerAccount, + Direction, + AccountType, + ) + + account = thl_lm.get_account_or_create_user_wallet(user=user) + assert isinstance(account, LedgerAccount) + + assert user.uuid in account.qualified_name + assert account.display_name == f"User Wallet {user.uuid}" + assert account.account_type == AccountType.USER_WALLET + assert account.normal_balance == Direction.CREDIT + assert account.reference_type == "user" + assert account.reference_uuid == user.uuid + assert account.currency == LedgerCurrency.TEST + + # Actually query for it to confirm + res = lm.get_account(qualified_name=account.qualified_name, raise_on_error=True) + assert res.model_dump_json() == account.model_dump_json() + + def test_get_account_or_create_bp_wallet(self, product, thl_lm, lm): + from generalresearch.currency import LedgerCurrency + from generalresearch.models.thl.ledger import ( + LedgerAccount, + Direction, + AccountType, + ) + + account = thl_lm.get_account_or_create_bp_wallet(product=product) + assert isinstance(account, LedgerAccount) + + assert product.uuid in account.qualified_name + assert account.display_name == f"BP Wallet {product.uuid}" + assert account.account_type == AccountType.BP_WALLET + assert account.normal_balance == Direction.CREDIT + assert account.reference_type == "bp" + assert account.reference_uuid == product.uuid + assert account.currency == LedgerCurrency.TEST + + # Actually query for it to confirm + res = lm.get_account(qualified_name=account.qualified_name, raise_on_error=True) + assert res.model_dump_json() == account.model_dump_json() + + def test_get_account_or_create_bp_commission(self, product, thl_lm, lm): + from generalresearch.currency import LedgerCurrency + from generalresearch.models.thl.ledger import ( + Direction, + AccountType, + ) + + account = thl_lm.get_account_or_create_bp_commission(product=product) + + assert product.uuid in account.qualified_name + assert account.display_name == f"Revenue from commission {product.uuid}" + assert account.account_type == AccountType.REVENUE + assert account.normal_balance == Direction.CREDIT + assert account.reference_type == "bp" + assert account.reference_uuid == product.uuid + assert account.currency == LedgerCurrency.TEST + + # Actually query for it to confirm + res = lm.get_account(qualified_name=account.qualified_name, raise_on_error=True) + assert res.model_dump_json() == account.model_dump_json() + + @pytest.mark.parametrize("expense", ["tango", "paypal", "gift", "tremendous"]) + def test_get_account_or_create_bp_expense(self, product, expense, thl_lm, lm): + from generalresearch.currency import LedgerCurrency + from generalresearch.models.thl.ledger import ( + Direction, + AccountType, + ) + + account = thl_lm.get_account_or_create_bp_expense( + product=product, expense_name=expense + ) + assert product.uuid in account.qualified_name + assert account.display_name == f"Expense {expense} {product.uuid}" + assert account.account_type == AccountType.EXPENSE + assert account.normal_balance == Direction.DEBIT + assert account.reference_type == "bp" + assert account.reference_uuid == product.uuid + assert account.currency == LedgerCurrency.TEST + + # Actually query for it to confirm + res = lm.get_account(qualified_name=account.qualified_name, raise_on_error=True) + assert res.model_dump_json() == account.model_dump_json() + + def test_get_or_create_bp_pending_payout_account(self, product, thl_lm, lm): + from generalresearch.currency import LedgerCurrency + from generalresearch.models.thl.ledger import ( + Direction, + AccountType, + ) + + account = thl_lm.get_or_create_bp_pending_payout_account(product=product) + + assert product.uuid in account.qualified_name + assert account.display_name == f"BP Wallet Pending {product.uuid}" + assert account.account_type == AccountType.BP_WALLET + assert account.normal_balance == Direction.CREDIT + assert account.reference_type == "bp" + assert account.reference_uuid == product.uuid + assert account.currency == LedgerCurrency.TEST + + # Actually query for it to confirm + res = lm.get_account(qualified_name=account.qualified_name, raise_on_error=True) + assert res.model_dump_json() == account.model_dump_json() + + def test_get_account_task_complete_revenue_raises( + self, delete_ledger_db, thl_lm, lm + ): + from generalresearch.managers.thl.ledger_manager.exceptions import ( + LedgerAccountDoesntExistError, + ) + + delete_ledger_db() + + with pytest.raises(expected_exception=LedgerAccountDoesntExistError): + thl_lm.get_account_task_complete_revenue() + + def test_get_account_task_complete_revenue( + self, account_cash, account_revenue_task_complete, thl_lm, lm + ): + from generalresearch.models.thl.ledger import ( + LedgerAccount, + AccountType, + ) + + res = thl_lm.get_account_task_complete_revenue() + assert isinstance(res, LedgerAccount) + assert res.reference_type is None + assert res.reference_uuid is None + assert res.account_type == AccountType.REVENUE + assert res.display_name == "Cash flow task complete" + + def test_get_account_cash_raises(self, delete_ledger_db, thl_lm, lm): + from generalresearch.managers.thl.ledger_manager.exceptions import ( + LedgerAccountDoesntExistError, + ) + + delete_ledger_db() + + with pytest.raises(expected_exception=LedgerAccountDoesntExistError): + thl_lm.get_account_cash() + + def test_get_account_cash(self, account_cash, thl_lm, lm): + from generalresearch.models.thl.ledger import ( + LedgerAccount, + AccountType, + ) + + res = thl_lm.get_account_cash() + assert isinstance(res, LedgerAccount) + assert res.reference_type is None + assert res.reference_uuid is None + assert res.account_type == AccountType.CASH + assert res.display_name == "Operating Cash Account" + + def test_get_accounts(self, setup_accounts, product, user_factory, thl_lm, lm, lam): + from generalresearch.models.thl.user import User + from generalresearch.managers.thl.ledger_manager.exceptions import ( + LedgerAccountDoesntExistError, + ) + + user1: User = user_factory(product=product) + user2: User = user_factory(product=product) + + account1 = thl_lm.get_account_or_create_bp_wallet(product=product) + + # (1) known account and confirm it comes back + res = lm.get_account(qualified_name=account1.qualified_name) + assert account1.model_dump_json() == res.model_dump_json() + + # (2) known accounts and confirm they both come back + res = lam.get_accounts(qualified_names=[account1.qualified_name]) + assert isinstance(res, list) + assert len(res) == 1 + assert account1 in res + + # Get 2 known and 1 made up qualified names, and confirm it raises + # an error + with pytest.raises(LedgerAccountDoesntExistError): + lam.get_accounts( + qualified_names=[ + account1.qualified_name, + f"test:bp_wall:{uuid4().hex}", + ] + ) + + def test_get_accounts_if_exists(self, product_factory, currency, thl_lm, lm): + from generalresearch.models.thl.product import Product + + p1: Product = product_factory() + p2: Product = product_factory() + + account1 = thl_lm.get_account_or_create_bp_wallet(product=p1) + account2 = thl_lm.get_account_or_create_bp_wallet(product=p2) + + # (1) known account and confirm it comes back + res = lm.get_account(qualified_name=account1.qualified_name) + assert account1.model_dump_json() == res.model_dump_json() + + # (2) known accounts and confirm they both come back + res = lm.get_accounts( + qualified_names=[account1.qualified_name, account2.qualified_name] + ) + assert isinstance(res, list) + assert len(res) == 2 + assert account1 in res + assert account2 in res + + # Get 2 known and 1 made up qualified names, and confirm only 2 + # come back + lm.get_accounts_if_exists( + qualified_names=[ + account1.qualified_name, + account2.qualified_name, + f"{currency.value}:bp_wall:{uuid4().hex}", + ] + ) + + assert isinstance(res, list) + assert len(res) == 2 + + # Confirm an empty array comes back for all unknown qualified names + res = lm.get_accounts_if_exists( + qualified_names=[ + f"{lm.currency.value}:bp_wall:{uuid4().hex}" for i in range(5) + ] + ) + assert isinstance(res, list) + assert len(res) == 0 + + def test_get_accounts_for_products(self, product_factory, thl_lm, lm): + from generalresearch.managers.thl.ledger_manager.exceptions import ( + LedgerAccountDoesntExistError, + ) + from generalresearch.models.thl.ledger import ( + LedgerAccount, + ) + + # Create 5 Products + product_uuids = [] + for i in range(5): + _p = product_factory() + product_uuids.append(_p.uuid) + + # Confirm that this fails.. because none of those accounts have been + # created yet + with pytest.raises(expected_exception=LedgerAccountDoesntExistError): + thl_lm.get_accounts_bp_wallet_for_products(product_uuids=product_uuids) + + # Create the bp_wallet accounts and then try again + for p_uuid in product_uuids: + thl_lm.get_account_or_create_bp_wallet_by_uuid(product_uuid=p_uuid) + + res = thl_lm.get_accounts_bp_wallet_for_products(product_uuids=product_uuids) + assert len(res) == len(product_uuids) + assert all([isinstance(i, LedgerAccount) for i in res]) + + +class TestLedgerAccountManager: + + def test_get_or_create(self, thl_lm, lm, lam): + from generalresearch.managers.thl.ledger_manager.exceptions import ( + LedgerAccountDoesntExistError, + ) + from generalresearch.models.thl.ledger import ( + LedgerAccount, + Direction, + AccountType, + ) + + u = uuid4().hex + name = f"test-{u[:8]}" + + account = LedgerAccount( + display_name=name, + qualified_name=f"test:bp_wallet:{u}", + normal_balance=Direction.DEBIT, + account_type=AccountType.BP_WALLET, + currency="test", + reference_type="bp", + reference_uuid=u, + ) + + # First we want to validate that using the get_account method raises + # an error for a random LedgerAccount which we know does not exist. + with pytest.raises(LedgerAccountDoesntExistError): + lam.get_account(qualified_name=account.qualified_name) + + # Now that we know it doesn't exist, get_or_create for it + instance = lam.get_account_or_create(account=account) + + # It should always return + assert isinstance(instance, LedgerAccount) + assert instance.reference_uuid == u + + def test_get(self, user, thl_lm, lm, lam): + from generalresearch.managers.thl.ledger_manager.exceptions import ( + LedgerAccountDoesntExistError, + ) + from generalresearch.models.thl.ledger import ( + LedgerAccount, + AccountType, + ) + + with pytest.raises(LedgerAccountDoesntExistError): + lam.get_account(qualified_name=f"test:bp_wallet:{user.product.id}") + + thl_lm.get_account_or_create_bp_wallet(product=user.product) + account = lam.get_account(qualified_name=f"test:bp_wallet:{user.product.id}") + + assert isinstance(account, LedgerAccount) + assert AccountType.BP_WALLET == account.account_type + assert user.product.uuid == account.reference_uuid + + def test_get_many(self, product_factory, thl_lm, lm, lam, currency): + from generalresearch.models.thl.product import Product + from generalresearch.managers.thl.ledger_manager.exceptions import ( + LedgerAccountDoesntExistError, + ) + + p1: Product = product_factory() + p2: Product = product_factory() + + account1 = thl_lm.get_account_or_create_bp_wallet(product=p1) + account2 = thl_lm.get_account_or_create_bp_wallet(product=p2) + + # Get 1 known account and confirm it comes back + res = lam.get_account_many( + qualified_names=[account1.qualified_name, account2.qualified_name] + ) + assert isinstance(res, list) + assert len(res) == 2 + assert account1 in res + + # Get 2 known accounts and confirm they both come back + res = lam.get_account_many( + qualified_names=[account1.qualified_name, account2.qualified_name] + ) + assert isinstance(res, list) + assert len(res) == 2 + assert account1 in res + assert account2 in res + + # Get 2 known and 1 made up qualified names, and confirm only 2 come + # back. Don't raise on error, so we can confirm the array is "short" + res = lam.get_account_many( + qualified_names=[ + account1.qualified_name, + account2.qualified_name, + f"test:bp_wall:{uuid4().hex}", + ], + raise_on_error=False, + ) + assert isinstance(res, list) + assert len(res) == 2 + + # Same as above, but confirm the raise works on checking res length + with pytest.raises(LedgerAccountDoesntExistError): + lam.get_account_many( + qualified_names=[ + account1.qualified_name, + account2.qualified_name, + f"test:bp_wall:{uuid4().hex}", + ], + raise_on_error=True, + ) + + # Confirm an empty array comes back for all unknown qualified names + res = lam.get_account_many( + qualified_names=[f"test:bp_wall:{uuid4().hex}" for i in range(5)], + raise_on_error=False, + ) + assert isinstance(res, list) + assert len(res) == 0 + + def test_create_account(self, thl_lm, lm, lam): + from generalresearch.models.thl.ledger import ( + LedgerAccount, + Direction, + AccountType, + ) + + u = uuid4().hex + name = f"test-{u[:8]}" + + account = LedgerAccount( + display_name=name, + qualified_name=f"test:bp_wallet:{u}", + normal_balance=Direction.DEBIT, + account_type=AccountType.BP_WALLET, + currency="test", + reference_type="bp", + reference_uuid=u, + ) + + lam.create_account(account=account) + assert lam.get_account(f"test:bp_wallet:{u}") == account + assert lam.get_account_or_create(account) == account diff --git a/tests/managers/thl/test_ledger/test_thl_lm_bp_payout.py b/tests/managers/thl/test_ledger/test_thl_lm_bp_payout.py new file mode 100644 index 0000000..294d092 --- /dev/null +++ b/tests/managers/thl/test_ledger/test_thl_lm_bp_payout.py @@ -0,0 +1,516 @@ +import logging +from datetime import datetime, timezone, timedelta +from decimal import Decimal +from random import randint +from uuid import uuid4 + +import pytest +import redis +from pydantic import RedisDsn +from redis.lock import Lock + +from generalresearch.currency import USDCent +from generalresearch.managers.base import Permission +from generalresearch.managers.thl.ledger_manager.thl_ledger import ThlLedgerManager +from generalresearch.managers.thl.ledger_manager.exceptions import ( + LedgerTransactionFlagAlreadyExistsError, + LedgerTransactionConditionFailedError, + LedgerTransactionReleaseLockError, + LedgerTransactionCreateError, +) +from generalresearch.managers.thl.ledger_manager.ledger import LedgerTransaction +from generalresearch.models import Source +from generalresearch.models.thl.definitions import PayoutStatus +from generalresearch.models.thl.ledger import Direction, TransactionType +from generalresearch.models.thl.session import ( + Wall, + Status, + StatusCode1, + Session, +) +from generalresearch.models.thl.user import User +from generalresearch.models.thl.wallet import PayoutType +from generalresearch.redis_helper import RedisConfig + + +def broken_acquire(self, *args, **kwargs): + raise redis.exceptions.TimeoutError("Simulated timeout during acquire") + + +def broken_release(self, *args, **kwargs): + raise redis.exceptions.TimeoutError("Simulated timeout during release") + + +class TestThlLedgerManagerBPPayout: + + def test_create_tx_with_bp_payment( + self, + user_factory, + product_user_wallet_no, + create_main_accounts, + caplog, + thl_lm, + delete_ledger_db, + ): + delete_ledger_db() + create_main_accounts() + + now = datetime.now(timezone.utc) - timedelta(hours=1) + user: User = user_factory(product=product_user_wallet_no) + + wall1 = Wall( + user_id=user.user_id, + source=Source.DYNATA, + req_survey_id="xxx", + req_cpi=Decimal("6.00"), + session_id=1, + status=Status.COMPLETE, + status_code_1=StatusCode1.COMPLETE, + started=now, + finished=now + timedelta(seconds=1), + ) + tx = thl_lm.create_tx_task_complete( + wall=wall1, user=user, created=wall1.started + ) + assert isinstance(tx, LedgerTransaction) + + session = Session(started=wall1.started, user=user, wall_events=[wall1]) + status, status_code_1 = session.determine_session_status() + thl_net, commission_amount, bp_pay, user_pay = session.determine_payments() + session.update( + **{ + "status": status, + "status_code_1": status_code_1, + "finished": now + timedelta(minutes=10), + "payout": bp_pay, + "user_payout": user_pay, + } + ) + thl_lm.create_tx_bp_payment(session=session, created=wall1.started) + + lock_key = f"test:bp_payout:{user.product.id}" + flag_name = f"{thl_lm.cache_prefix}:transaction_flag:{lock_key}" + thl_lm.redis_client.delete(flag_name) + + payoutevent_uuid = uuid4().hex + thl_lm.create_tx_bp_payout( + product=user.product, + amount=USDCent(200), + created=now, + payoutevent_uuid=payoutevent_uuid, + ) + + payoutevent_uuid = uuid4().hex + thl_lm.create_tx_bp_payout( + product=user.product, + amount=USDCent(200), + created=now + timedelta(minutes=2), + skip_one_per_day_check=True, + payoutevent_uuid=payoutevent_uuid, + ) + + cash = thl_lm.get_account_cash() + bp_wallet_account = thl_lm.get_account_or_create_bp_wallet(user.product) + assert 170 == thl_lm.get_account_balance(bp_wallet_account) + assert 200 == thl_lm.get_account_balance(cash) + + with pytest.raises(expected_exception=LedgerTransactionFlagAlreadyExistsError): + thl_lm.create_tx_bp_payout( + user.product, + amount=USDCent(200), + created=now + timedelta(minutes=2), + skip_one_per_day_check=False, + skip_wallet_balance_check=False, + payoutevent_uuid=payoutevent_uuid, + ) + + payoutevent_uuid = uuid4().hex + with caplog.at_level(logging.INFO): + with pytest.raises(LedgerTransactionConditionFailedError): + thl_lm.create_tx_bp_payout( + user.product, + amount=USDCent(10_000), + created=now + timedelta(minutes=2), + skip_one_per_day_check=True, + skip_wallet_balance_check=False, + payoutevent_uuid=payoutevent_uuid, + ) + assert "failed condition check balance:" in caplog.text + + thl_lm.create_tx_bp_payout( + product=user.product, + amount=USDCent(10_00), + created=now + timedelta(minutes=2), + skip_one_per_day_check=True, + skip_wallet_balance_check=True, + payoutevent_uuid=payoutevent_uuid, + ) + assert 170 - 1000 == thl_lm.get_account_balance(bp_wallet_account) + + def test_create_tx(self, product, caplog, thl_lm, currency): + rand_amount: USDCent = USDCent(randint(100, 1_000)) + payoutevent_uuid = uuid4().hex + + # Create a BP Payout for a Product without any activity. By issuing, + # the skip_* checks, we should be able to force it to work, and will + # then ultimately result in a negative balance + tx = thl_lm.create_tx_bp_payout( + product=product, + amount=rand_amount, + payoutevent_uuid=payoutevent_uuid, + created=datetime.now(tz=timezone.utc), + skip_wallet_balance_check=True, + skip_one_per_day_check=True, + skip_flag_check=True, + ) + + # Check the basic attributes + assert isinstance(tx, LedgerTransaction) + assert tx.ext_description == "BP Payout" + assert ( + tx.tag + == f"{currency.value}:{TransactionType.BP_PAYOUT.value}:{payoutevent_uuid}" + ) + assert tx.entries[0].amount == rand_amount + assert tx.entries[1].amount == rand_amount + + # Check the Product's balance, it should be negative the amount that was + # paid out. That's because the Product earned nothing.. and then was + # sent something. + balance = thl_lm.get_account_balance( + account=thl_lm.get_account_or_create_bp_wallet(product=product) + ) + assert balance == int(rand_amount) * -1 + + # Test some basic assertions + with caplog.at_level(logging.INFO): + with pytest.raises(expected_exception=Exception): + thl_lm.create_tx_bp_payout( + product=product, + amount=rand_amount, + payoutevent_uuid=uuid4().hex, + created=datetime.now(tz=timezone.utc), + skip_wallet_balance_check=False, + skip_one_per_day_check=False, + skip_flag_check=False, + ) + assert "failed condition check >1 tx per day" in caplog.text + + def test_create_tx_redis_failure(self, product, thl_web_rw, thl_lm): + rand_amount: USDCent = USDCent(randint(100, 1_000)) + payoutevent_uuid = uuid4().hex + now = datetime.now(tz=timezone.utc) + + thl_lm.create_tx_plug_bp_wallet( + product, rand_amount, now, direction=Direction.CREDIT + ) + + # Non routable IP address. Redis will fail + thl_lm_redis_0 = ThlLedgerManager( + pg_config=thl_web_rw, + permissions=[ + Permission.CREATE, + Permission.READ, + Permission.UPDATE, + Permission.DELETE, + ], + testing=True, + redis_config=RedisConfig( + dsn=RedisDsn("redis://10.255.255.1:6379"), + socket_connect_timeout=0.1, + ), + ) + + with pytest.raises(expected_exception=Exception) as e: + tx = thl_lm_redis_0.create_tx_bp_payout( + product=product, + amount=rand_amount, + payoutevent_uuid=payoutevent_uuid, + created=datetime.now(tz=timezone.utc), + ) + assert e.type is redis.exceptions.TimeoutError + # No txs were created + bp_wallet_account = thl_lm.get_account_or_create_bp_wallet(product=product) + txs = thl_lm.get_tx_filtered_by_account(account_uuid=bp_wallet_account.uuid) + txs = [tx for tx in txs if tx.metadata["tx_type"] != "plug"] + assert len(txs) == 0 + + def test_create_tx_multiple_per_day(self, product, thl_lm): + rand_amount: USDCent = USDCent(randint(100, 1_000)) + payoutevent_uuid = uuid4().hex + now = datetime.now(tz=timezone.utc) + + thl_lm.create_tx_plug_bp_wallet( + product, rand_amount * USDCent(2), now, direction=Direction.CREDIT + ) + + tx = thl_lm.create_tx_bp_payout( + product=product, + amount=rand_amount, + payoutevent_uuid=payoutevent_uuid, + created=datetime.now(tz=timezone.utc), + ) + + # Try to create another + # Will fail b/c it has the same payout event uuid + with pytest.raises(expected_exception=Exception) as e: + tx = thl_lm.create_tx_bp_payout( + product=product, + amount=rand_amount, + payoutevent_uuid=payoutevent_uuid, + created=datetime.now(tz=timezone.utc), + ) + assert e.type is LedgerTransactionFlagAlreadyExistsError + + # Try to create another + # Will fail due to multiple per day + payoutevent_uuid2 = uuid4().hex + with pytest.raises(expected_exception=Exception) as e: + tx = thl_lm.create_tx_bp_payout( + product=product, + amount=rand_amount, + payoutevent_uuid=payoutevent_uuid2, + created=datetime.now(tz=timezone.utc), + ) + assert e.type is LedgerTransactionConditionFailedError + assert str(e.value) == ">1 tx per day" + + # Make it run by skipping one per day check + tx = thl_lm.create_tx_bp_payout( + product=product, + amount=rand_amount, + payoutevent_uuid=payoutevent_uuid2, + created=datetime.now(tz=timezone.utc), + skip_one_per_day_check=True, + ) + + def test_create_tx_redis_lock_release_error(self, product, thl_lm): + rand_amount: USDCent = USDCent(randint(100, 1_000)) + payoutevent_uuid = uuid4().hex + now = datetime.now(tz=timezone.utc) + bp_wallet_account = thl_lm.get_account_or_create_bp_wallet(product=product) + + thl_lm.create_tx_plug_bp_wallet( + product, rand_amount * USDCent(2), now, direction=Direction.CREDIT + ) + + original_acquire = Lock.acquire + original_release = Lock.release + Lock.acquire = broken_acquire + + # Create TX will fail on lock enter, no tx will actually get created + with pytest.raises(expected_exception=Exception) as e: + tx = thl_lm.create_tx_bp_payout( + product=product, + amount=rand_amount, + payoutevent_uuid=payoutevent_uuid, + created=datetime.now(tz=timezone.utc), + ) + assert e.type is LedgerTransactionCreateError + assert str(e.value) == "Redis error: Simulated timeout during acquire" + txs = thl_lm.get_tx_filtered_by_account(account_uuid=bp_wallet_account.uuid) + txs = [tx for tx in txs if tx.metadata["tx_type"] != "plug"] + assert len(txs) == 0 + + Lock.acquire = original_acquire + Lock.release = broken_release + + # Create TX will fail on lock exit, after the tx was created! + with pytest.raises(expected_exception=Exception) as e: + tx = thl_lm.create_tx_bp_payout( + product=product, + amount=rand_amount, + payoutevent_uuid=payoutevent_uuid, + created=datetime.now(tz=timezone.utc), + ) + assert e.type is LedgerTransactionReleaseLockError + assert str(e.value) == "Redis error: Simulated timeout during release" + + # Transaction was still created! + txs = thl_lm.get_tx_filtered_by_account(account_uuid=bp_wallet_account.uuid) + txs = [tx for tx in txs if tx.metadata["tx_type"] != "plug"] + assert len(txs) == 1 + Lock.release = original_release + + +class TestPayoutEventManagerBPPayout: + + def test_create(self, product, thl_lm, brokerage_product_payout_event_manager): + rand_amount: USDCent = USDCent(randint(100, 1_000)) + now = datetime.now(tz=timezone.utc) + bp_wallet_account = thl_lm.get_account_or_create_bp_wallet(product=product) + assert thl_lm.get_account_balance(bp_wallet_account) == 0 + thl_lm.create_tx_plug_bp_wallet( + product, rand_amount, now, direction=Direction.CREDIT + ) + assert thl_lm.get_account_balance(bp_wallet_account) == rand_amount + brokerage_product_payout_event_manager.set_account_lookup_table(thl_lm=thl_lm) + + pe = brokerage_product_payout_event_manager.create_bp_payout_event( + thl_ledger_manager=thl_lm, + product=product, + created=now, + amount=rand_amount, + payout_type=PayoutType.ACH, + ) + assert brokerage_product_payout_event_manager.check_for_ledger_tx( + thl_ledger_manager=thl_lm, + product_id=product.id, + amount=rand_amount, + payout_event=pe, + ) + assert thl_lm.get_account_balance(bp_wallet_account) == 0 + + def test_create_with_redis_error( + self, product, caplog, thl_lm, brokerage_product_payout_event_manager + ): + caplog.set_level("WARNING") + original_acquire = Lock.acquire + original_release = Lock.release + + rand_amount: USDCent = USDCent(randint(100, 1_000)) + now = datetime.now(tz=timezone.utc) + bp_wallet_account = thl_lm.get_account_or_create_bp_wallet(product=product) + assert thl_lm.get_account_balance(bp_wallet_account) == 0 + thl_lm.create_tx_plug_bp_wallet( + product, rand_amount, now, direction=Direction.CREDIT + ) + assert thl_lm.get_account_balance(bp_wallet_account) == rand_amount + brokerage_product_payout_event_manager.set_account_lookup_table(thl_lm=thl_lm) + + # Will fail on lock enter, no tx will actually get created + Lock.acquire = broken_acquire + with pytest.raises(expected_exception=Exception) as e: + pe = brokerage_product_payout_event_manager.create_bp_payout_event( + thl_ledger_manager=thl_lm, + product=product, + created=now, + amount=rand_amount, + payout_type=PayoutType.ACH, + ) + assert e.type is LedgerTransactionCreateError + assert str(e.value) == "Redis error: Simulated timeout during acquire" + assert any( + "Simulated timeout during acquire. No ledger tx was created" in m + for m in caplog.messages + ) + + txs = thl_lm.get_tx_filtered_by_account(account_uuid=bp_wallet_account.uuid) + txs = [tx for tx in txs if tx.metadata["tx_type"] != "plug"] + # One payout event is created, status is failed, and no ledger txs exist + assert len(txs) == 0 + pes = ( + brokerage_product_payout_event_manager.get_bp_bp_payout_events_for_products( + thl_ledger_manager=thl_lm, product_uuids=[product.id] + ) + ) + assert len(pes) == 1 + assert pes[0].status == PayoutStatus.FAILED + pe = pes[0] + + # Fix the redis method + Lock.acquire = original_acquire + + # Try to fix the failed payout, by trying ledger tx again + brokerage_product_payout_event_manager.retry_create_bp_payout_event_tx( + product=product, thl_ledger_manager=thl_lm, payout_event_uuid=pe.uuid + ) + txs = thl_lm.get_tx_filtered_by_account(account_uuid=bp_wallet_account.uuid) + txs = [tx for tx in txs if tx.metadata["tx_type"] != "plug"] + assert len(txs) == 1 + assert thl_lm.get_account_balance(bp_wallet_account) == 0 + + # And then try to run it again, it'll fail because a payout event with the same info exists + with pytest.raises(expected_exception=Exception) as e: + pe = brokerage_product_payout_event_manager.create_bp_payout_event( + thl_ledger_manager=thl_lm, + product=product, + created=now, + amount=rand_amount, + payout_type=PayoutType.ACH, + ) + assert e.type is ValueError + assert "Payout event already exists!" in str(e.value) + + # We wouldn't do this in practice, because this is paying out the BP again, but + # we can if want to. + # Change the timestamp so it'll create a new payout event + now = datetime.now(tz=timezone.utc) + with pytest.raises(LedgerTransactionConditionFailedError) as e: + pe = brokerage_product_payout_event_manager.create_bp_payout_event( + thl_ledger_manager=thl_lm, + product=product, + created=now, + amount=rand_amount, + payout_type=PayoutType.ACH, + ) + # But it will fail due to 1 per day check + assert str(e.value) == ">1 tx per day" + pe = brokerage_product_payout_event_manager.get_by_uuid(e.value.pe_uuid) + assert pe.status == PayoutStatus.FAILED + + # And if we really want to, we can make it again + now = datetime.now(tz=timezone.utc) + pe = brokerage_product_payout_event_manager.create_bp_payout_event( + thl_ledger_manager=thl_lm, + product=product, + created=now, + amount=rand_amount, + payout_type=PayoutType.ACH, + skip_one_per_day_check=True, + skip_wallet_balance_check=True, + ) + + txs = thl_lm.get_tx_filtered_by_account(account_uuid=bp_wallet_account.uuid) + txs = [tx for tx in txs if tx.metadata["tx_type"] != "plug"] + assert len(txs) == 2 + # since they were paid twice + assert thl_lm.get_account_balance(bp_wallet_account) == 0 - rand_amount + + Lock.release = original_release + Lock.acquire = original_acquire + + def test_create_with_redis_error_release( + self, product, caplog, thl_lm, brokerage_product_payout_event_manager + ): + caplog.set_level("WARNING") + + original_release = Lock.release + + rand_amount: USDCent = USDCent(randint(100, 1_000)) + now = datetime.now(tz=timezone.utc) + bp_wallet_account = thl_lm.get_account_or_create_bp_wallet(product=product) + brokerage_product_payout_event_manager.set_account_lookup_table(thl_lm=thl_lm) + + assert thl_lm.get_account_balance(bp_wallet_account) == 0 + thl_lm.create_tx_plug_bp_wallet( + product, rand_amount, now, direction=Direction.CREDIT + ) + assert thl_lm.get_account_balance(bp_wallet_account) == rand_amount + + # Will fail on lock exit, after the tx was created! + # But it'll see that the tx was created and so everything will be fine + Lock.release = broken_release + pe = brokerage_product_payout_event_manager.create_bp_payout_event( + thl_ledger_manager=thl_lm, + product=product, + created=now, + amount=rand_amount, + payout_type=PayoutType.ACH, + ) + assert any( + "Simulated timeout during release but ledger tx exists" in m + for m in caplog.messages + ) + + txs = thl_lm.get_tx_filtered_by_account(account_uuid=bp_wallet_account.uuid) + txs = [tx for tx in txs if tx.metadata["tx_type"] != "plug"] + assert len(txs) == 1 + pes = ( + brokerage_product_payout_event_manager.get_bp_bp_payout_events_for_products( + thl_ledger_manager=thl_lm, product_uuids=[product.uuid] + ) + ) + assert len(pes) == 1 + assert pes[0].status == PayoutStatus.COMPLETE + Lock.release = original_release diff --git a/tests/managers/thl/test_ledger/test_thl_lm_tx.py b/tests/managers/thl/test_ledger/test_thl_lm_tx.py new file mode 100644 index 0000000..31c7107 --- /dev/null +++ b/tests/managers/thl/test_ledger/test_thl_lm_tx.py @@ -0,0 +1,1762 @@ +import logging +from datetime import datetime, timezone, timedelta +from decimal import Decimal +from random import randint +from uuid import uuid4 + +import pytest + +from generalresearch.currency import USDCent +from generalresearch.managers.thl.ledger_manager.ledger import ( + LedgerTransaction, +) +from generalresearch.models import Source +from generalresearch.models.thl.definitions import ( + WALL_ALLOWED_STATUS_STATUS_CODE, +) +from generalresearch.models.thl.ledger import Direction +from generalresearch.models.thl.ledger import TransactionType +from generalresearch.models.thl.product import ( + PayoutConfig, + PayoutTransformation, + UserWalletConfig, +) +from generalresearch.models.thl.session import ( + Wall, + Status, + StatusCode1, + Session, + WallAdjustedStatus, +) +from generalresearch.models.thl.user import User +from generalresearch.models.thl.wallet import PayoutType +from generalresearch.models.thl.payout import UserPayoutEvent + +logger = logging.getLogger("LedgerManager") + + +class TestThlLedgerTxManager: + + def test_create_tx_task_complete( + self, + wall, + user, + account_revenue_task_complete, + create_main_accounts, + thl_lm, + lm, + ): + create_main_accounts() + tx = thl_lm.create_tx_task_complete(wall=wall, user=user) + assert isinstance(tx, LedgerTransaction) + + res = lm.get_tx_by_id(transaction_id=tx.id) + assert res.created == tx.created + + def test_create_tx_task_complete_( + self, wall, user, account_revenue_task_complete, thl_lm, lm + ): + tx = thl_lm.create_tx_task_complete_(wall=wall, user=user) + assert isinstance(tx, LedgerTransaction) + + res = lm.get_tx_by_id(transaction_id=tx.id) + assert res.created == tx.created + + def test_create_tx_bp_payment( + self, + session_factory, + user, + create_main_accounts, + delete_ledger_db, + thl_lm, + lm, + session_manager, + ): + delete_ledger_db() + create_main_accounts() + s1 = session_factory(user=user) + + status, status_code_1 = s1.determine_session_status() + thl_net, commission_amount, bp_pay, user_pay = s1.determine_payments() + session_manager.finish_with_status( + session=s1, + status=Status.COMPLETE, + status_code_1=status_code_1, + finished=datetime.now(tz=timezone.utc) + timedelta(minutes=10), + payout=bp_pay, + user_payout=user_pay, + ) + + tx = thl_lm.create_tx_bp_payment(session=s1) + assert isinstance(tx, LedgerTransaction) + + res = lm.get_tx_by_id(transaction_id=tx.id) + assert res.created == tx.created + + def test_create_tx_bp_payment_amt( + self, + session_factory, + user_factory, + product_manager, + create_main_accounts, + delete_ledger_db, + thl_lm, + lm, + session_manager, + ): + delete_ledger_db() + create_main_accounts() + product = product_manager.create_dummy( + payout_config=PayoutConfig( + payout_transformation=PayoutTransformation( + f="payout_transformation_amt" + ) + ), + user_wallet_config=UserWalletConfig(amt=True, enabled=True), + ) + user = user_factory(product=product) + s1 = session_factory(user=user, wall_req_cpi=Decimal("1")) + + status, status_code_1 = s1.determine_session_status() + assert status == Status.COMPLETE + thl_net, commission_amount, bp_pay, user_pay = s1.determine_payments( + thl_ledger_manager=thl_lm + ) + print(thl_net, commission_amount, bp_pay, user_pay) + session_manager.finish_with_status( + session=s1, + status=Status.COMPLETE, + status_code_1=status_code_1, + finished=datetime.now(tz=timezone.utc) + timedelta(minutes=10), + payout=bp_pay, + user_payout=user_pay, + ) + + tx = thl_lm.create_tx_bp_payment(session=s1) + assert isinstance(tx, LedgerTransaction) + + res = lm.get_tx_by_id(transaction_id=tx.id) + assert res.created == tx.created + + def test_create_tx_bp_payment_( + self, + session_factory, + user, + create_main_accounts, + thl_lm, + lm, + session_manager, + utc_hour_ago, + ): + s1 = session_factory(user=user) + status, status_code_1 = s1.determine_session_status() + thl_net, commission_amount, bp_pay, user_pay = s1.determine_payments() + session_manager.finish_with_status( + session=s1, + status=status, + status_code_1=status_code_1, + finished=utc_hour_ago + timedelta(minutes=10), + payout=bp_pay, + user_payout=user_pay, + ) + + s1.determine_payments() + tx = thl_lm.create_tx_bp_payment_(session=s1) + assert isinstance(tx, LedgerTransaction) + + res = lm.get_tx_by_id(transaction_id=tx.id) + assert res.created == tx.created + + def test_create_tx_task_adjustment( + self, wall_factory, session, user, create_main_accounts, thl_lm, lm + ): + """Create Wall event Complete, and Create a Tx Task Adjustment + + - I don't know what this does exactly... but we can confirm + the transaction comes back with balanced amounts, and that + the name of the Source is in the Tx description + """ + + wall_status = Status.COMPLETE + wall: Wall = wall_factory(session=session, wall_status=wall_status) + + tx = thl_lm.create_tx_task_adjustment(wall=wall, user=user) + assert isinstance(tx, LedgerTransaction) + res = lm.get_tx_by_id(transaction_id=tx.id) + + assert res.entries[0].amount == int(wall.cpi * 100) + assert res.entries[1].amount == int(wall.cpi * 100) + assert wall.source.name in res.ext_description + assert res.created == tx.created + + def test_create_tx_bp_adjustment(self, session, user, caplog, thl_lm, lm): + status, status_code_1 = session.determine_session_status() + thl_net, commission_amount, bp_pay, user_pay = session.determine_payments() + + # The default session fixture is just an unfinished wall event + assert len(session.wall_events) == 1 + assert session.finished is None + assert status == Status.TIMEOUT + assert status_code_1 in list( + WALL_ALLOWED_STATUS_STATUS_CODE.get(Status.TIMEOUT, {}) + ) + assert thl_net == Decimal(0) + assert commission_amount == Decimal(0) + assert bp_pay == Decimal(0) + assert user_pay is None + + # Update the finished timestamp, but nothing else. This means that + # there is no financial changes needed + session.update( + **{ + "finished": datetime.now(tz=timezone.utc) + timedelta(minutes=10), + } + ) + assert session.finished + with caplog.at_level(logging.INFO): + tx = thl_lm.create_tx_bp_adjustment(session=session) + assert tx is None + assert "No transactions needed." in caplog.text + + def test_create_tx_bp_payout(self, product, caplog, thl_lm, currency): + rand_amount: USDCent = USDCent(randint(100, 1_000)) + payoutevent_uuid = uuid4().hex + + # Create a BP Payout for a Product without any activity. By issuing, + # the skip_* checks, we should be able to force it to work, and will + # then ultimately result in a negative balance + tx = thl_lm.create_tx_bp_payout( + product=product, + amount=rand_amount, + payoutevent_uuid=payoutevent_uuid, + created=datetime.now(tz=timezone.utc), + skip_wallet_balance_check=True, + skip_one_per_day_check=True, + skip_flag_check=True, + ) + + # Check the basic attributes + assert isinstance(tx, LedgerTransaction) + assert tx.ext_description == "BP Payout" + assert ( + tx.tag + == f"{thl_lm.currency.value}:{TransactionType.BP_PAYOUT.value}:{payoutevent_uuid}" + ) + assert tx.entries[0].amount == rand_amount + assert tx.entries[1].amount == rand_amount + + # Check the Product's balance, it should be negative the amount that was + # paid out. That's because the Product earned nothing.. and then was + # sent something. + balance = thl_lm.get_account_balance( + account=thl_lm.get_account_or_create_bp_wallet(product=product) + ) + assert balance == int(rand_amount) * -1 + + # Test some basic assertions + with caplog.at_level(logging.INFO): + with pytest.raises(expected_exception=Exception): + thl_lm.create_tx_bp_payout( + product=product, + amount=rand_amount, + payoutevent_uuid=uuid4().hex, + created=datetime.now(tz=timezone.utc), + skip_wallet_balance_check=False, + skip_one_per_day_check=False, + skip_flag_check=False, + ) + assert "failed condition check >1 tx per day" in caplog.text + + def test_create_tx_bp_payout_(self, product, thl_lm, lm, currency): + rand_amount: USDCent = USDCent(randint(100, 1_000)) + payoutevent_uuid = uuid4().hex + + # Create a BP Payout for a Product without any activity. + tx = thl_lm.create_tx_bp_payout_( + product=product, + amount=rand_amount, + payoutevent_uuid=payoutevent_uuid, + created=datetime.now(tz=timezone.utc), + ) + + # Check the basic attributes + assert isinstance(tx, LedgerTransaction) + assert tx.ext_description == "BP Payout" + assert ( + tx.tag + == f"{currency.value}:{TransactionType.BP_PAYOUT.value}:{payoutevent_uuid}" + ) + assert tx.entries[0].amount == rand_amount + assert tx.entries[1].amount == rand_amount + + def test_create_tx_plug_bp_wallet( + self, product, create_main_accounts, thl_lm, lm, currency + ): + """A BP Wallet "plug" is a way to makeup discrepancies and simply + add or remove money + """ + rand_amount: USDCent = USDCent(randint(100, 1_000)) + + tx = thl_lm.create_tx_plug_bp_wallet( + product=product, + amount=rand_amount, + created=datetime.now(tz=timezone.utc), + direction=Direction.DEBIT, + skip_flag_check=False, + ) + + assert isinstance(tx, LedgerTransaction) + + # We issued the BP money they didn't earn, so now they have a + # negative balance + balance = thl_lm.get_account_balance( + account=thl_lm.get_account_or_create_bp_wallet(product=product) + ) + assert balance == int(rand_amount) * -1 + + def test_create_tx_plug_bp_wallet_( + self, product, create_main_accounts, thl_lm, lm, currency + ): + """A BP Wallet "plug" is a way to fix discrepancies and simply + add or remove money. + + Similar to above, but because it's unprotected, we can immediately + issue another to see if the balance changes + """ + rand_amount: USDCent = USDCent(randint(100, 1_000)) + + tx = thl_lm.create_tx_plug_bp_wallet_( + product=product, + amount=rand_amount, + created=datetime.now(tz=timezone.utc), + direction=Direction.DEBIT, + ) + + assert isinstance(tx, LedgerTransaction) + + # We issued the BP money they didn't earn, so now they have a + # negative balance + balance = thl_lm.get_account_balance( + account=thl_lm.get_account_or_create_bp_wallet(product=product) + ) + assert balance == int(rand_amount) * -1 + + # Issue a positive one now, and confirm the balance goes positive + thl_lm.create_tx_plug_bp_wallet_( + product=product, + amount=rand_amount + rand_amount, + created=datetime.now(tz=timezone.utc), + direction=Direction.CREDIT, + ) + balance = thl_lm.get_account_balance( + account=thl_lm.get_account_or_create_bp_wallet(product=product) + ) + assert balance == int(rand_amount) + + def test_create_tx_user_payout_request( + self, + user, + product_user_wallet_yes, + user_factory, + delete_df_collection, + thl_lm, + lm, + currency, + ): + pe = UserPayoutEvent( + uuid=uuid4().hex, + payout_type=PayoutType.PAYPAL, + amount=500, + cashout_method_uuid=uuid4().hex, + debit_account_uuid=uuid4().hex, + ) + + # The default user fixture uses a product that doesn't have wallet + # mode enabled + with pytest.raises(expected_exception=AssertionError): + thl_lm.create_tx_user_payout_request( + user=user, + payout_event=pe, + skip_flag_check=True, + skip_wallet_balance_check=True, + ) + + # Now try it for a user on a product with wallet mode + u2 = user_factory(product=product_user_wallet_yes) + + # User's pre-balance is 0 because no activity has occurred yet + pre_balance = lm.get_account_balance( + account=thl_lm.get_account_or_create_user_wallet(user=u2) + ) + assert pre_balance == 0 + + tx = thl_lm.create_tx_user_payout_request( + user=u2, + payout_event=pe, + skip_flag_check=True, + skip_wallet_balance_check=True, + ) + assert isinstance(tx, LedgerTransaction) + assert tx.entries[0].amount == pe.amount + assert tx.entries[1].amount == pe.amount + assert tx.ext_description == "User Payout Paypal Request $5.00" + + # + # (TODO): This key ":user_payout:" is NOT part of the TransactionType + # enum and was manually set. It should be based off the + # TransactionType names. + # + + assert tx.tag == f"{currency.value}:user_payout:{pe.uuid}:request" + + # Post balance is -$5.00 because it comes out of the wallet before + # it's Approved or Completed + post_balance = lm.get_account_balance( + account=thl_lm.get_account_or_create_user_wallet(user=u2) + ) + assert post_balance == -500 + + def test_create_tx_user_payout_request_( + self, + user, + product_user_wallet_yes, + user_factory, + delete_ledger_db, + thl_lm, + lm, + ): + delete_ledger_db() + + pe = UserPayoutEvent( + uuid=uuid4().hex, + payout_type=PayoutType.PAYPAL, + amount=500, + cashout_method_uuid=uuid4().hex, + debit_account_uuid=uuid4().hex, + ) + + rand_description = uuid4().hex + tx = thl_lm.create_tx_user_payout_request_( + user=user, payout_event=pe, description=rand_description + ) + + assert tx.ext_description == rand_description + + post_balance = lm.get_account_balance( + account=thl_lm.get_account_or_create_user_wallet(user=user) + ) + assert post_balance == -500 + + def test_create_tx_user_payout_complete( + self, + user_factory, + product_user_wallet_yes, + create_main_accounts, + delete_ledger_db, + thl_lm, + lm, + currency, + ): + delete_ledger_db() + create_main_accounts() + + user: User = user_factory(product=product_user_wallet_yes) + user_account = thl_lm.get_account_or_create_user_wallet(user=user) + rand_amount = randint(100, 1_000) + + # Ensure the user starts out with nothing... + assert lm.get_account_balance(account=user_account) == 0 + + pe = UserPayoutEvent( + uuid=uuid4().hex, + payout_type=PayoutType.PAYPAL, + amount=rand_amount, + cashout_method_uuid=uuid4().hex, + debit_account_uuid=uuid4().hex, + ) + + # Confirm it's not possible unless a request occurred happen + with pytest.raises(expected_exception=ValueError): + thl_lm.create_tx_user_payout_complete( + user=user, + payout_event=pe, + fee_amount=None, + skip_flag_check=False, + ) + + # (1) Make a request first + thl_lm.create_tx_user_payout_request( + user=user, + payout_event=pe, + skip_flag_check=True, + skip_wallet_balance_check=True, + ) + # Assert the balance came out of their user wallet + assert lm.get_account_balance(account=user_account) == rand_amount * -1 + + # (2) Complete the request + tx = thl_lm.create_tx_user_payout_complete( + user=user, + payout_event=pe, + fee_amount=Decimal(0), + skip_flag_check=False, + ) + assert tx.entries[0].amount == rand_amount + assert tx.entries[1].amount == rand_amount + assert tx.tag == f"{currency.value}:user_payout:{pe.uuid}:complete" + assert isinstance(tx, LedgerTransaction) + + # The amount that comes out of the user wallet doesn't change after + # it's approved becuase it's already been withdrawn + assert lm.get_account_balance(account=user_account) == rand_amount * -1 + + def test_create_tx_user_payout_complete_( + self, + user_factory, + product_user_wallet_yes, + create_main_accounts, + thl_lm, + lm, + ): + user: User = user_factory(product=product_user_wallet_yes) + user_account = thl_lm.get_account_or_create_user_wallet(user=user) + rand_amount = randint(100, 1_000) + + pe = UserPayoutEvent( + uuid=uuid4().hex, + payout_type=PayoutType.PAYPAL, + amount=rand_amount, + cashout_method_uuid=uuid4().hex, + debit_account_uuid=uuid4().hex, + ) + + # (1) Make a request first + thl_lm.create_tx_user_payout_request( + user=user, + payout_event=pe, + skip_flag_check=True, + skip_wallet_balance_check=True, + ) + + # (2) Complete the request + rand_desc = uuid4().hex + + bp_expense_account = thl_lm.get_account_or_create_bp_expense( + product=user.product, expense_name="paypal" + ) + bp_wallet_account = thl_lm.get_account_or_create_bp_wallet(product=user.product) + + tx = thl_lm.create_tx_user_payout_complete_( + user=user, + payout_event=pe, + fee_amount=Decimal("0.00"), + fee_expense_account=bp_expense_account, + fee_payer_account=bp_wallet_account, + description=rand_desc, + ) + assert tx.ext_description == rand_desc + assert lm.get_account_balance(account=user_account) == rand_amount * -1 + + def test_create_tx_user_payout_cancelled( + self, + user_factory, + product_user_wallet_yes, + create_main_accounts, + thl_lm, + lm, + currency, + ): + user: User = user_factory(product=product_user_wallet_yes) + user_account = thl_lm.get_account_or_create_user_wallet(user=user) + rand_amount = randint(100, 1_000) + + pe = UserPayoutEvent( + uuid=uuid4().hex, + payout_type=PayoutType.PAYPAL, + amount=rand_amount, + cashout_method_uuid=uuid4().hex, + debit_account_uuid=uuid4().hex, + ) + + # (1) Make a request first + thl_lm.create_tx_user_payout_request( + user=user, + payout_event=pe, + skip_flag_check=True, + skip_wallet_balance_check=True, + ) + # Assert the balance came out of their user wallet + assert lm.get_account_balance(account=user_account) == rand_amount * -1 + + # (2) Cancel the request + tx = thl_lm.create_tx_user_payout_cancelled( + user=user, + payout_event=pe, + skip_flag_check=False, + ) + assert tx.entries[0].amount == rand_amount + assert tx.entries[1].amount == rand_amount + assert tx.tag == f"{currency.value}:user_payout:{pe.uuid}:cancel" + assert isinstance(tx, LedgerTransaction) + + # Assert the balance comes back to 0 after it was cancelled + assert lm.get_account_balance(account=user_account) == 0 + + def test_create_tx_user_payout_cancelled_( + self, + user_factory, + product_user_wallet_yes, + create_main_accounts, + thl_lm, + lm, + currency, + ): + user: User = user_factory(product=product_user_wallet_yes) + user_account = thl_lm.get_account_or_create_user_wallet(user=user) + rand_amount = randint(100, 1_000) + + pe = UserPayoutEvent( + uuid=uuid4().hex, + payout_type=PayoutType.PAYPAL, + amount=rand_amount, + cashout_method_uuid=uuid4().hex, + debit_account_uuid=uuid4().hex, + ) + + # (1) Make a request first + thl_lm.create_tx_user_payout_request( + user=user, + payout_event=pe, + skip_flag_check=True, + skip_wallet_balance_check=True, + ) + # Assert the balance came out of their user wallet + assert lm.get_account_balance(account=user_account) == rand_amount * -1 + + # (2) Cancel the request + rand_desc = uuid4().hex + tx = thl_lm.create_tx_user_payout_cancelled_( + user=user, payout_event=pe, description=rand_desc + ) + assert isinstance(tx, LedgerTransaction) + assert tx.ext_description == rand_desc + assert lm.get_account_balance(account=user_account) == 0 + + def test_create_tx_user_bonus( + self, + user_factory, + product_user_wallet_yes, + create_main_accounts, + thl_lm, + lm, + currency, + ): + user: User = user_factory(product=product_user_wallet_yes) + user_account = thl_lm.get_account_or_create_user_wallet(user=user) + rand_amount = randint(100, 1_000) + rand_ref_uuid = uuid4().hex + rand_desc = uuid4().hex + + # Assert the balance came out of their user wallet + assert lm.get_account_balance(account=user_account) == 0 + + tx = thl_lm.create_tx_user_bonus( + user=user, + amount=Decimal(rand_amount / 100), + ref_uuid=rand_ref_uuid, + description=rand_desc, + skip_flag_check=True, + ) + assert tx.ext_description == rand_desc + assert tx.tag == f"{thl_lm.currency.value}:user_bonus:{rand_ref_uuid}" + assert tx.entries[0].amount == rand_amount + assert tx.entries[1].amount == rand_amount + + # Assert the balance came out of their user wallet + assert lm.get_account_balance(account=user_account) == rand_amount + + def test_create_tx_user_bonus_( + self, + user_factory, + product_user_wallet_yes, + create_main_accounts, + thl_lm, + lm, + currency, + ): + user: User = user_factory(product=product_user_wallet_yes) + user_account = thl_lm.get_account_or_create_user_wallet(user=user) + rand_amount = randint(100, 1_000) + rand_ref_uuid = uuid4().hex + rand_desc = uuid4().hex + + # Assert the balance came out of their user wallet + assert lm.get_account_balance(account=user_account) == 0 + + tx = thl_lm.create_tx_user_bonus_( + user=user, + amount=Decimal(rand_amount / 100), + ref_uuid=rand_ref_uuid, + description=rand_desc, + ) + assert tx.ext_description == rand_desc + assert tx.tag == f"{thl_lm.currency.value}:user_bonus:{rand_ref_uuid}" + assert tx.entries[0].amount == rand_amount + assert tx.entries[1].amount == rand_amount + + # Assert the balance came out of their user wallet + assert lm.get_account_balance(account=user_account) == rand_amount + + +class TestThlLedgerTxManagerFlows: + """Combine the various THL_LM methods to create actual "real world" + examples + """ + + def test_create_tx_task_complete( + self, user, create_main_accounts, thl_lm, lm, currency, delete_ledger_db + ): + delete_ledger_db() + create_main_accounts() + + wall1 = Wall( + user_id=1, + source=Source.DYNATA, + req_survey_id="xxx", + req_cpi=Decimal("1.23"), + session_id=1, + status=Status.COMPLETE, + status_code_1=StatusCode1.COMPLETE, + started=datetime.now(timezone.utc), + finished=datetime.now(timezone.utc) + timedelta(seconds=1), + ) + thl_lm.create_tx_task_complete(wall=wall1, user=user, created=wall1.started) + + wall2 = Wall( + user_id=1, + source=Source.FULL_CIRCLE, + req_survey_id="yyy", + req_cpi=Decimal("3.21"), + session_id=1, + status=Status.COMPLETE, + status_code_1=StatusCode1.COMPLETE, + started=datetime.now(timezone.utc), + finished=datetime.now(timezone.utc) + timedelta(seconds=1), + ) + thl_lm.create_tx_task_complete(wall=wall2, user=user, created=wall2.started) + + cash = thl_lm.get_account_cash() + revenue = thl_lm.get_account_task_complete_revenue() + + assert lm.get_account_balance(cash) == 123 + 321 + assert lm.get_account_balance(revenue) == 123 + 321 + assert lm.check_ledger_balanced() + + assert ( + lm.get_account_filtered_balance( + account=revenue, metadata_key="source", metadata_value="d" + ) + == 123 + ) + + assert ( + lm.get_account_filtered_balance( + account=revenue, metadata_key="source", metadata_value="f" + ) + == 321 + ) + + assert ( + lm.get_account_filtered_balance( + account=revenue, metadata_key="source", metadata_value="x" + ) + == 0 + ) + + assert ( + thl_lm.get_account_filtered_balance( + account=revenue, + metadata_key="thl_wall", + metadata_value=wall1.uuid, + ) + == 123 + ) + + def test_create_transaction_task_complete_1_cent( + self, user, create_main_accounts, thl_lm, lm, currency + ): + wall1 = Wall( + user_id=1, + source=Source.DYNATA, + req_survey_id="xxx", + req_cpi=Decimal("0.007"), + session_id=1, + status=Status.COMPLETE, + status_code_1=StatusCode1.COMPLETE, + started=datetime.now(timezone.utc), + finished=datetime.now(timezone.utc) + timedelta(seconds=1), + ) + tx = thl_lm.create_tx_task_complete( + wall=wall1, user=user, created=wall1.started + ) + + assert isinstance(tx, LedgerTransaction) + + def test_create_transaction_bp_payment( + self, + user, + create_main_accounts, + thl_lm, + lm, + currency, + delete_ledger_db, + session_factory, + utc_hour_ago, + ): + delete_ledger_db() + create_main_accounts() + + s1: Session = session_factory( + user=user, + wall_count=1, + started=utc_hour_ago, + wall_source=Source.TESTING, + ) + w1: Wall = s1.wall_events[0] + + tx = thl_lm.create_tx_task_complete(wall=w1, user=user, created=w1.started) + assert isinstance(tx, LedgerTransaction) + + status, status_code_1 = s1.determine_session_status() + thl_net, commission_amount, bp_pay, user_pay = s1.determine_payments() + s1.update( + **{ + "status": status, + "status_code_1": status_code_1, + "finished": s1.started + timedelta(minutes=10), + "payout": bp_pay, + "user_payout": user_pay, + } + ) + print(thl_net, commission_amount, bp_pay, user_pay) + thl_lm.create_tx_bp_payment(session=s1, created=w1.started) + + revenue = thl_lm.get_account_task_complete_revenue() + bp_wallet = thl_lm.get_account_or_create_bp_wallet(product=user.product) + bp_commission = thl_lm.get_account_or_create_bp_commission(product=user.product) + + assert 0 == lm.get_account_balance(account=revenue) + assert 50 == lm.get_account_filtered_balance( + account=revenue, + metadata_key="source", + metadata_value=Source.TESTING, + ) + assert 48 == lm.get_account_balance(account=bp_wallet) + assert 48 == lm.get_account_filtered_balance( + account=bp_wallet, + metadata_key="thl_session", + metadata_value=s1.uuid, + ) + assert 2 == thl_lm.get_account_balance(account=bp_commission) + assert thl_lm.check_ledger_balanced() + + def test_create_transaction_bp_payment_round( + self, + user_factory, + product_user_wallet_no, + create_main_accounts, + thl_lm, + lm, + currency, + ): + product_user_wallet_no.commission_pct = Decimal("0.085") + user: User = user_factory(product=product_user_wallet_no) + + wall1 = Wall( + user_id=user.user_id, + source=Source.SAGO, + req_survey_id="xxx", + req_cpi=Decimal("0.287"), + session_id=3, + status=Status.COMPLETE, + status_code_1=StatusCode1.COMPLETE, + started=datetime.now(timezone.utc), + finished=datetime.now(timezone.utc) + timedelta(seconds=1), + ) + + tx = thl_lm.create_tx_task_complete( + wall=wall1, user=user, created=wall1.started + ) + assert isinstance(tx, LedgerTransaction) + + session = Session(started=wall1.started, user=user, wall_events=[wall1]) + status, status_code_1 = session.determine_session_status() + thl_net, commission_amount, bp_pay, user_pay = session.determine_payments() + session.update( + **{ + "status": status, + "status_code_1": status_code_1, + "finished": session.started + timedelta(minutes=10), + "payout": bp_pay, + "user_payout": user_pay, + } + ) + + print(thl_net, commission_amount, bp_pay, user_pay) + tx = thl_lm.create_tx_bp_payment(session=session, created=wall1.started) + assert isinstance(tx, LedgerTransaction) + + def test_create_transaction_bp_payment_round2( + self, delete_ledger_db, user, create_main_accounts, thl_lm, lm, currency + ): + delete_ledger_db() + create_main_accounts() + # user must be no user wallet + # e.g. session 869b5bfa47f44b4f81cd095ed01df2ff this fails if you dont round properly + + wall1 = Wall( + user_id=user.user_id, + source=Source.SAGO, + req_survey_id="xxx", + req_cpi=Decimal("1.64500"), + session_id=3, + status=Status.COMPLETE, + status_code_1=StatusCode1.COMPLETE, + started=datetime.now(timezone.utc), + finished=datetime.now(timezone.utc) + timedelta(seconds=1), + ) + + thl_lm.create_tx_task_complete(wall=wall1, user=user, created=wall1.started) + session = Session(started=wall1.started, user=user, wall_events=[wall1]) + status, status_code_1 = session.determine_session_status() + # thl_net, commission_amount, bp_pay, user_pay = session.determine_payments() + session.update( + **{ + "status": status, + "status_code_1": status_code_1, + "finished": session.started + timedelta(minutes=10), + "payout": Decimal("1.53"), + "user_payout": Decimal("1.53"), + } + ) + + thl_lm.create_tx_bp_payment(session=session, created=wall1.started) + + def test_create_transaction_bp_payment_round3( + self, + user_factory, + product_user_wallet_yes, + create_main_accounts, + thl_lm, + lm, + currency, + ): + # e.g. session ___ fails b/c we rounded incorrectly + # before, and now we are off by a penny... + user: User = user_factory(product=product_user_wallet_yes) + + wall1 = Wall( + user_id=user.user_id, + source=Source.SAGO, + req_survey_id="xxx", + req_cpi=Decimal("0.385"), + session_id=3, + status=Status.COMPLETE, + status_code_1=StatusCode1.COMPLETE, + started=datetime.now(timezone.utc), + finished=datetime.now(timezone.utc) + timedelta(seconds=1), + ) + thl_lm.create_tx_task_complete(wall=wall1, user=user, created=wall1.started) + + session = Session(started=wall1.started, user=user, wall_events=[wall1]) + status, status_code_1 = session.determine_session_status() + # thl_net, commission_amount, bp_pay, user_pay = session.determine_payments() + session.update( + **{ + "status": status, + "status_code_1": status_code_1, + "finished": session.started + timedelta(minutes=10), + "payout": Decimal("0.39"), + "user_payout": Decimal("0.26"), + } + ) + # with pytest.logs(logger, level=logging.WARNING) as cm: + # tx = thl_lm.create_transaction_bp_payment(session, created=wall1.started) + # assert "Capping bp_pay to thl_net" in cm.output[0] + + def test_create_transaction_bp_payment_user_wallet( + self, + user_factory, + product_user_wallet_yes, + create_main_accounts, + delete_ledger_db, + thl_lm, + session_manager, + wall_manager, + lm, + session_factory, + currency, + utc_hour_ago, + ): + delete_ledger_db() + create_main_accounts() + + user: User = user_factory(product=product_user_wallet_yes) + assert user.product.user_wallet_enabled + + s1: Session = session_factory( + user=user, + wall_count=1, + started=utc_hour_ago, + wall_req_cpi=Decimal(".50"), + wall_source=Source.TESTING, + ) + w1: Wall = s1.wall_events[0] + + thl_lm.create_tx_task_complete(wall=w1, user=user, created=w1.started) + + status, status_code_1 = s1.determine_session_status() + thl_net, commission_amount, bp_pay, user_pay = s1.determine_payments() + session_manager.finish_with_status( + session=s1, + status=status, + status_code_1=status_code_1, + finished=s1.started + timedelta(minutes=10), + payout=bp_pay, + user_payout=user_pay, + ) + thl_lm.create_tx_bp_payment(session=s1, created=w1.started) + + revenue = thl_lm.get_account_task_complete_revenue() + bp_wallet = thl_lm.get_account_or_create_bp_wallet(product=user.product) + bp_commission = thl_lm.get_account_or_create_bp_commission(product=user.product) + user_wallet = thl_lm.get_account_or_create_user_wallet(user=user) + + assert 0 == thl_lm.get_account_balance(account=revenue) + assert 50 == thl_lm.get_account_filtered_balance( + account=revenue, + metadata_key="source", + metadata_value=Source.TESTING, + ) + + assert 48 - 19 == thl_lm.get_account_balance(account=bp_wallet) + assert 48 - 19 == thl_lm.get_account_filtered_balance( + account=bp_wallet, + metadata_key="thl_session", + metadata_value=s1.uuid, + ) + assert 2 == thl_lm.get_account_balance(bp_commission) + assert 19 == thl_lm.get_account_balance(user_wallet) + assert 19 == thl_lm.get_account_filtered_balance( + account=user_wallet, + metadata_key="thl_session", + metadata_value=s1.uuid, + ) + + assert 0 == thl_lm.get_account_filtered_balance( + account=user_wallet, metadata_key="thl_session", metadata_value="x" + ) + assert thl_lm.check_ledger_balanced() + + +class TestThlLedgerManagerAdj: + + def test_create_tx_task_adjustment( + self, + user_factory, + product_user_wallet_no, + create_main_accounts, + delete_ledger_db, + thl_lm, + lm, + utc_hour_ago, + currency, + ): + delete_ledger_db() + create_main_accounts() + + user: User = user_factory(product=product_user_wallet_no) + + wall1 = Wall( + user_id=user.user_id, + source=Source.DYNATA, + req_survey_id="xxx", + req_cpi=Decimal("1.23"), + session_id=1, + status=Status.COMPLETE, + status_code_1=StatusCode1.COMPLETE, + started=utc_hour_ago, + finished=utc_hour_ago + timedelta(seconds=1), + ) + + thl_lm.create_tx_task_complete(wall1, user, created=wall1.started) + + wall2 = Wall( + user_id=1, + source=Source.FULL_CIRCLE, + req_survey_id="yyy", + req_cpi=Decimal("3.21"), + session_id=1, + status=Status.COMPLETE, + status_code_1=StatusCode1.COMPLETE, + started=utc_hour_ago, + finished=utc_hour_ago + timedelta(seconds=1), + ) + thl_lm.create_tx_task_complete(wall2, user, created=wall2.started) + + wall1.update( + adjusted_status=WallAdjustedStatus.ADJUSTED_TO_FAIL, + adjusted_cpi=0, + adjusted_timestamp=utc_hour_ago + timedelta(hours=1), + ) + print(wall1.get_cpi_after_adjustment()) + thl_lm.create_tx_task_adjustment(wall1, user) + + cash = thl_lm.get_account_cash() + revenue = thl_lm.get_account_task_complete_revenue() + + assert 123 + 321 - 123 == thl_lm.get_account_balance(account=cash) + assert 123 + 321 - 123 == thl_lm.get_account_balance(account=revenue) + assert thl_lm.check_ledger_balanced() + assert 0 == thl_lm.get_account_filtered_balance( + revenue, metadata_key="source", metadata_value="d" + ) + assert 321 == thl_lm.get_account_filtered_balance( + revenue, metadata_key="source", metadata_value="f" + ) + assert 0 == thl_lm.get_account_filtered_balance( + revenue, metadata_key="source", metadata_value="x" + ) + assert 123 - 123 == thl_lm.get_account_filtered_balance( + account=revenue, metadata_key="thl_wall", metadata_value=wall1.uuid + ) + + # un-reconcile it + wall1.update( + adjusted_status=None, + adjusted_cpi=None, + adjusted_timestamp=utc_hour_ago + timedelta(minutes=45), + ) + print(wall1.get_cpi_after_adjustment()) + thl_lm.create_tx_task_adjustment(wall1, user) + # and then run it again to make sure it does nothing + thl_lm.create_tx_task_adjustment(wall1, user) + + cash = thl_lm.get_account_cash() + revenue = thl_lm.get_account_task_complete_revenue() + + assert 123 + 321 - 123 + 123 == thl_lm.get_account_balance(cash) + assert 123 + 321 - 123 + 123 == thl_lm.get_account_balance(revenue) + assert thl_lm.check_ledger_balanced() + assert 123 == thl_lm.get_account_filtered_balance( + account=revenue, metadata_key="source", metadata_value="d" + ) + assert 321 == thl_lm.get_account_filtered_balance( + account=revenue, metadata_key="source", metadata_value="f" + ) + assert 0 == thl_lm.get_account_filtered_balance( + account=revenue, metadata_key="source", metadata_value="x" + ) + assert 123 - 123 + 123 == thl_lm.get_account_filtered_balance( + account=revenue, metadata_key="thl_wall", metadata_value=wall1.uuid + ) + + def test_create_tx_bp_adjustment( + self, + user, + product_user_wallet_no, + create_main_accounts, + caplog, + thl_lm, + lm, + currency, + session_manager, + wall_manager, + session_factory, + utc_hour_ago, + delete_ledger_db, + ): + delete_ledger_db() + create_main_accounts() + + s1 = session_factory( + user=user, + wall_count=2, + wall_req_cpis=[Decimal(1), Decimal(3)], + wall_statuses=[Status.COMPLETE, Status.COMPLETE], + started=utc_hour_ago, + ) + + w1: Wall = s1.wall_events[0] + w2: Wall = s1.wall_events[1] + + thl_lm.create_tx_task_complete(wall=w1, user=user, created=w1.started) + thl_lm.create_tx_task_complete(wall=w2, user=user, created=w2.started) + + status, status_code_1 = s1.determine_session_status() + thl_net, commission_amount, bp_pay, user_pay = s1.determine_payments() + session_manager.finish_with_status( + session=s1, + status=status, + status_code_1=status_code_1, + finished=utc_hour_ago + timedelta(minutes=10), + payout=bp_pay, + user_payout=user_pay, + ) + thl_lm.create_tx_bp_payment(session=s1, created=w1.started) + revenue = thl_lm.get_account_task_complete_revenue() + bp_wallet_account = thl_lm.get_account_or_create_bp_wallet(product=user.product) + bp_commission_account = thl_lm.get_account_or_create_bp_commission( + product=user.product + ) + assert 380 == thl_lm.get_account_balance(account=bp_wallet_account) + assert 0 == thl_lm.get_account_balance(account=revenue) + assert 20 == thl_lm.get_account_balance(account=bp_commission_account) + thl_lm.check_ledger_balanced() + + # This should do nothing (since we haven't adjusted any wall events) + s1.adjust_status() + with caplog.at_level(logging.INFO): + thl_lm.create_tx_bp_adjustment(session=s1) + + assert ( + "create_transaction_bp_adjustment. No transactions needed." in caplog.text + ) + + # self.assertEqual(380, ledger_manager.get_account_balance(bp_wallet_account)) + # self.assertEqual(0, ledger_manager.get_account_balance(revenue)) + # self.assertEqual(20, ledger_manager.get_account_balance(bp_commission_account)) + # self.assertTrue(ledger_manager.check_ledger_balanced()) + + # recon $1 survey. + wall_manager.adjust_status( + wall=w1, + adjusted_status=WallAdjustedStatus.ADJUSTED_TO_FAIL, + adjusted_cpi=Decimal(0), + adjusted_timestamp=utc_hour_ago + timedelta(hours=1), + ) + thl_lm.create_tx_task_adjustment(wall=w1, user=user) + # -$1.00 b/c the MP took the $1 back, but we haven't yet taken the BP payment back + assert -100 == thl_lm.get_account_balance(revenue) + s1.adjust_status() + thl_lm.create_tx_bp_adjustment(session=s1) + + with caplog.at_level(logging.INFO): + thl_lm.create_tx_bp_adjustment(session=s1) + assert ( + "create_transaction_bp_adjustment. No transactions needed." in caplog.text + ) + + assert 380 - 95 == thl_lm.get_account_balance(bp_wallet_account) + assert 0 == thl_lm.get_account_balance(revenue) + assert 20 - 5 == thl_lm.get_account_balance(bp_commission_account) + assert thl_lm.check_ledger_balanced() + + # unrecon the $1 survey + wall_manager.adjust_status( + wall=w1, + adjusted_status=None, + adjusted_cpi=None, + adjusted_timestamp=utc_hour_ago + timedelta(minutes=45), + ) + thl_lm.create_tx_task_adjustment( + wall=w1, + user=user, + created=utc_hour_ago + timedelta(minutes=45), + ) + new_status, new_payout, new_user_payout = s1.determine_new_status_and_payouts() + s1.adjust_status() + thl_lm.create_tx_bp_adjustment(session=s1) + assert 380 == thl_lm.get_account_balance(bp_wallet_account) + assert 0 == thl_lm.get_account_balance(revenue) + assert 20, thl_lm.get_account_balance(bp_commission_account) + assert thl_lm.check_ledger_balanced() + + def test_create_tx_bp_adjustment_small( + self, + user_factory, + product_user_wallet_no, + create_main_accounts, + delete_ledger_db, + thl_lm, + lm, + utc_hour_ago, + currency, + ): + delete_ledger_db() + create_main_accounts() + + # This failed when I didn't check that `change_commission` > 0 in + # create_transaction_bp_adjustment + user: User = user_factory(product=product_user_wallet_no) + + wall1 = Wall( + user_id=user.user_id, + source=Source.DYNATA, + req_survey_id="xxx", + req_cpi=Decimal("0.10"), + session_id=1, + status=Status.COMPLETE, + status_code_1=StatusCode1.COMPLETE, + started=utc_hour_ago, + finished=utc_hour_ago + timedelta(seconds=1), + ) + + tx = thl_lm.create_tx_task_complete( + wall=wall1, user=user, created=wall1.started + ) + assert isinstance(tx, LedgerTransaction) + + session = Session(started=wall1.started, user=user, wall_events=[wall1]) + status, status_code_1 = session.determine_session_status() + thl_net, commission_amount, bp_pay, user_pay = session.determine_payments() + session.update( + **{ + "status": status, + "status_code_1": status_code_1, + "finished": utc_hour_ago + timedelta(minutes=10), + "payout": bp_pay, + "user_payout": user_pay, + } + ) + thl_lm.create_tx_bp_payment(session, created=wall1.started) + + wall1.update( + adjusted_status=WallAdjustedStatus.ADJUSTED_TO_FAIL, + adjusted_cpi=0, + adjusted_timestamp=utc_hour_ago + timedelta(hours=1), + ) + thl_lm.create_tx_task_adjustment(wall1, user) + session.adjust_status() + thl_lm.create_tx_bp_adjustment(session) + + def test_create_tx_bp_adjustment_abandon( + self, + user_factory, + product_user_wallet_no, + delete_ledger_db, + session_factory, + create_main_accounts, + caplog, + thl_lm, + lm, + currency, + utc_hour_ago, + session_manager, + wall_manager, + ): + delete_ledger_db() + create_main_accounts() + user: User = user_factory(product=product_user_wallet_no) + s1: Session = session_factory( + user=user, final_status=Status.ABANDON, wall_req_cpi=Decimal(1) + ) + w1 = s1.wall_events[-1] + + # Adjust to complete. + wall_manager.adjust_status( + wall=w1, + adjusted_status=WallAdjustedStatus.ADJUSTED_TO_COMPLETE, + adjusted_cpi=w1.cpi, + adjusted_timestamp=utc_hour_ago + timedelta(hours=1), + ) + thl_lm.create_tx_task_adjustment(wall=w1, user=user) + s1.adjust_status() + thl_lm.create_tx_bp_adjustment(session=s1) + # And then adjust it back (it was abandon before, but now it should be + # fail (?) or back to abandon?) + wall_manager.adjust_status( + wall=w1, + adjusted_status=None, + adjusted_cpi=None, + adjusted_timestamp=utc_hour_ago + timedelta(hours=1), + ) + thl_lm.create_tx_task_adjustment(wall=w1, user=user) + s1.adjust_status() + thl_lm.create_tx_bp_adjustment(session=s1) + + revenue = thl_lm.get_account_task_complete_revenue() + bp_wallet_account = thl_lm.get_account_or_create_bp_wallet(product=user.product) + bp_commission_account = thl_lm.get_account_or_create_bp_commission( + product=user.product + ) + assert 0 == thl_lm.get_account_balance(bp_wallet_account) + assert 0 == thl_lm.get_account_balance(revenue) + assert 0 == thl_lm.get_account_balance(bp_commission_account) + assert thl_lm.check_ledger_balanced() + + # This should do nothing + s1.adjust_status() + with caplog.at_level(logging.INFO): + thl_lm.create_tx_bp_adjustment(session=s1) + assert "No transactions needed" in caplog.text + + # Now back to complete again + wall_manager.adjust_status( + wall=w1, + adjusted_status=WallAdjustedStatus.ADJUSTED_TO_COMPLETE, + adjusted_cpi=w1.cpi, + adjusted_timestamp=utc_hour_ago + timedelta(hours=1), + ) + s1.adjust_status() + thl_lm.create_tx_bp_adjustment(session=s1) + assert 95 == thl_lm.get_account_balance(bp_wallet_account) + + def test_create_tx_bp_adjustment_user_wallet( + self, + user_factory, + product_user_wallet_yes, + create_main_accounts, + delete_ledger_db, + caplog, + thl_lm, + lm, + currency, + ): + delete_ledger_db() + create_main_accounts() + + now = datetime.now(timezone.utc) - timedelta(days=1) + user: User = user_factory(product=product_user_wallet_yes) + + # Create 2 Wall completes and create the respective transaction for + # them. We then create a 3rd wall event which is a failure but we + # do NOT create a transaction for it + + wall3 = Wall( + user_id=8, + source=Source.CINT, + req_survey_id="zzz", + req_cpi=Decimal("2.00"), + session_id=1, + status=Status.FAIL, + status_code_1=StatusCode1.BUYER_FAIL, + started=now, + finished=now + timedelta(minutes=1), + ) + + now_w1 = now + timedelta(minutes=1, milliseconds=1) + wall1 = Wall( + user_id=user.user_id, + source=Source.DYNATA, + req_survey_id="xxx", + req_cpi=Decimal("1.00"), + session_id=1, + status=Status.COMPLETE, + status_code_1=StatusCode1.COMPLETE, + started=now_w1, + finished=now_w1 + timedelta(minutes=1), + ) + tx = thl_lm.create_tx_task_complete( + wall=wall1, user=user, created=wall1.started + ) + assert isinstance(tx, LedgerTransaction) + + now_w2 = now + timedelta(minutes=2, milliseconds=1) + wall2 = Wall( + user_id=user.user_id, + source=Source.FULL_CIRCLE, + req_survey_id="yyy", + req_cpi=Decimal("3.00"), + session_id=1, + status=Status.COMPLETE, + status_code_1=StatusCode1.COMPLETE, + started=now_w2, + finished=now_w2 + timedelta(minutes=1), + ) + tx = thl_lm.create_tx_task_complete( + wall=wall2, user=user, created=wall2.started + ) + assert isinstance(tx, LedgerTransaction) + + # It doesn't matter what order these wall events go in as because + # we have a pydantic valiator that sorts them + wall_events = [wall3, wall1, wall2] + # shuffle(wall_events) + session = Session(started=wall1.started, user=user, wall_events=wall_events) + status, status_code_1 = session.determine_session_status() + assert status == Status.COMPLETE + assert status_code_1 == StatusCode1.COMPLETE + + thl_net, commission_amount, bp_pay, user_pay = session.determine_payments() + assert thl_net == Decimal("4.00") + assert commission_amount == Decimal("0.20") + assert bp_pay == Decimal("3.80") + assert user_pay == Decimal("1.52") + + session.update( + **{ + "status": status, + "status_code_1": status_code_1, + "finished": now + timedelta(minutes=10), + "payout": bp_pay, + "user_payout": user_pay, + } + ) + + tx = thl_lm.create_tx_bp_adjustment(session=session, created=wall1.started) + assert isinstance(tx, LedgerTransaction) + + bp_wallet_account = thl_lm.get_account_or_create_bp_wallet(product=user.product) + assert 228 == thl_lm.get_account_balance(account=bp_wallet_account) + + user_account = thl_lm.get_account_or_create_user_wallet(user=user) + assert 152 == thl_lm.get_account_balance(account=user_account) + + revenue = thl_lm.get_account_task_complete_revenue() + assert 0 == thl_lm.get_account_balance(account=revenue) + + bp_commission_account = thl_lm.get_account_or_create_bp_commission( + product=user.product + ) + assert 20 == thl_lm.get_account_balance(account=bp_commission_account) + + # the total (4.00) = 2.28 + 1.52 + .20 + assert thl_lm.check_ledger_balanced() + + # This should do nothing (since we haven't adjusted any wall events) + session.adjust_status() + print( + session.get_status_after_adjustment(), + session.get_payout_after_adjustment(), + session.get_user_payout_after_adjustment(), + ) + with caplog.at_level(logging.INFO): + thl_lm.create_tx_bp_adjustment(session) + assert ( + "create_transaction_bp_adjustment. No transactions needed." in caplog.text + ) + + # recon $1 survey. + wall1.update( + adjusted_status=WallAdjustedStatus.ADJUSTED_TO_FAIL, + adjusted_cpi=0, + adjusted_timestamp=now + timedelta(hours=1), + ) + thl_lm.create_tx_task_adjustment(wall1, user) + # -$1.00 b/c the MP took the $1 back, but we haven't yet taken the BP payment back + assert -100 == thl_lm.get_account_balance(revenue) + session.adjust_status() + print( + session.get_status_after_adjustment(), + session.get_payout_after_adjustment(), + session.get_user_payout_after_adjustment(), + ) + thl_lm.create_tx_bp_adjustment(session) + + # running this twice b/c it should do nothing the 2nd time + print( + session.get_status_after_adjustment(), + session.get_payout_after_adjustment(), + session.get_user_payout_after_adjustment(), + ) + with caplog.at_level(logging.INFO): + thl_lm.create_tx_bp_adjustment(session) + assert ( + "create_transaction_bp_adjustment. No transactions needed." in caplog.text + ) + + assert 228 - 57 == thl_lm.get_account_balance(bp_wallet_account) + assert 152 - 38 == thl_lm.get_account_balance(user_account) + assert 0 == thl_lm.get_account_balance(revenue) + assert 20 - 5 == thl_lm.get_account_balance(bp_commission_account) + assert thl_lm.check_ledger_balanced() + + # unrecon the $1 survey + wall1.update( + adjusted_status=None, + adjusted_cpi=None, + adjusted_timestamp=now + timedelta(hours=2), + ) + tx = thl_lm.create_tx_task_adjustment(wall=wall1, user=user) + assert isinstance(tx, LedgerTransaction) + + new_status, new_payout, new_user_payout = ( + session.determine_new_status_and_payouts() + ) + print(new_status, new_payout, new_user_payout, session.adjusted_payout) + session.adjust_status() + print( + session.get_status_after_adjustment(), + session.get_payout_after_adjustment(), + session.get_user_payout_after_adjustment(), + ) + thl_lm.create_tx_bp_adjustment(session) + + assert 228 - 57 + 57 == thl_lm.get_account_balance(bp_wallet_account) + assert 152 - 38 + 38 == thl_lm.get_account_balance(user_account) + assert 0 == thl_lm.get_account_balance(revenue) + assert 20 - 5 + 5 == thl_lm.get_account_balance(bp_commission_account) + assert thl_lm.check_ledger_balanced() + + # make the $2 failure into a complete also + wall3.update( + adjusted_status=WallAdjustedStatus.ADJUSTED_TO_COMPLETE, + adjusted_cpi=wall3.cpi, + adjusted_timestamp=now + timedelta(hours=2), + ) + thl_lm.create_tx_task_adjustment(wall3, user) + new_status, new_payout, new_user_payout = ( + session.determine_new_status_and_payouts() + ) + print(new_status, new_payout, new_user_payout, session.adjusted_payout) + session.adjust_status() + print( + session.get_status_after_adjustment(), + session.get_payout_after_adjustment(), + session.get_user_payout_after_adjustment(), + ) + thl_lm.create_tx_bp_adjustment(session) + assert 228 - 57 + 57 + 114 == thl_lm.get_account_balance(bp_wallet_account) + assert 152 - 38 + 38 + 76 == thl_lm.get_account_balance(user_account) + assert 0 == thl_lm.get_account_balance(revenue) + assert 20 - 5 + 5 + 10 == thl_lm.get_account_balance(bp_commission_account) + assert thl_lm.check_ledger_balanced() + + def test_create_transaction_bp_adjustment_cpi_adjustment( + self, + user_factory, + product_user_wallet_no, + create_main_accounts, + delete_ledger_db, + caplog, + thl_lm, + lm, + utc_hour_ago, + currency, + ): + delete_ledger_db() + create_main_accounts() + user: User = user_factory(product=product_user_wallet_no) + + wall1 = Wall( + user_id=user.user_id, + source=Source.DYNATA, + req_survey_id="xxx", + req_cpi=Decimal("1.00"), + session_id=1, + status=Status.COMPLETE, + status_code_1=StatusCode1.COMPLETE, + started=utc_hour_ago, + finished=utc_hour_ago + timedelta(seconds=1), + ) + tx = thl_lm.create_tx_task_complete( + wall=wall1, user=user, created=wall1.started + ) + assert isinstance(tx, LedgerTransaction) + + wall2 = Wall( + user_id=user.user_id, + source=Source.FULL_CIRCLE, + req_survey_id="yyy", + req_cpi=Decimal("3.00"), + session_id=1, + status=Status.COMPLETE, + status_code_1=StatusCode1.COMPLETE, + started=utc_hour_ago, + finished=utc_hour_ago + timedelta(seconds=1), + ) + tx = thl_lm.create_tx_task_complete( + wall=wall2, user=user, created=wall2.started + ) + assert isinstance(tx, LedgerTransaction) + + session = Session(started=wall1.started, user=user, wall_events=[wall1, wall2]) + status, status_code_1 = session.determine_session_status() + thl_net, commission_amount, bp_pay, user_pay = session.determine_payments() + session.update( + **{ + "status": status, + "status_code_1": status_code_1, + "finished": utc_hour_ago + timedelta(minutes=10), + "payout": bp_pay, + "user_payout": user_pay, + } + ) + thl_lm.create_tx_bp_payment(session, created=wall1.started) + + revenue = thl_lm.get_account_task_complete_revenue() + bp_wallet_account = thl_lm.get_account_or_create_bp_wallet(user.product) + bp_commission_account = thl_lm.get_account_or_create_bp_commission(user.product) + assert 380 == thl_lm.get_account_balance(bp_wallet_account) + assert 0 == thl_lm.get_account_balance(revenue) + assert 20 == thl_lm.get_account_balance(bp_commission_account) + assert thl_lm.check_ledger_balanced() + + # cpi adjustment $1 -> $.60. + wall1.update( + adjusted_status=WallAdjustedStatus.CPI_ADJUSTMENT, + adjusted_cpi=Decimal("0.60"), + adjusted_timestamp=utc_hour_ago + timedelta(minutes=30), + ) + thl_lm.create_tx_task_adjustment(wall1, user) + + # -$0.40 b/c the MP took $0.40 back, but we haven't yet taken the BP payment back + assert -40 == thl_lm.get_account_balance(revenue) + session.adjust_status() + print( + session.get_status_after_adjustment(), + session.get_payout_after_adjustment(), + session.get_user_payout_after_adjustment(), + ) + thl_lm.create_tx_bp_adjustment(session) + + # running this twice b/c it should do nothing the 2nd time + print( + session.get_status_after_adjustment(), + session.get_payout_after_adjustment(), + session.get_user_payout_after_adjustment(), + ) + with caplog.at_level(logging.INFO): + thl_lm.create_tx_bp_adjustment(session) + assert "create_transaction_bp_adjustment." in caplog.text + assert "No transactions needed." in caplog.text + + assert 380 - 38 == thl_lm.get_account_balance(bp_wallet_account) + assert 0 == thl_lm.get_account_balance(revenue) + assert 20 - 2 == thl_lm.get_account_balance(bp_commission_account) + assert thl_lm.check_ledger_balanced() + + # adjust it to failure + wall1.update( + adjusted_status=WallAdjustedStatus.ADJUSTED_TO_FAIL, + adjusted_cpi=0, + adjusted_timestamp=utc_hour_ago + timedelta(minutes=45), + ) + thl_lm.create_tx_task_adjustment(wall1, user) + session.adjust_status() + thl_lm.create_tx_bp_adjustment(session) + assert 300 - (300 * 0.05) == thl_lm.get_account_balance(bp_wallet_account) + assert 0 == thl_lm.get_account_balance(revenue) + assert 300 * 0.05 == thl_lm.get_account_balance(bp_commission_account) + assert thl_lm.check_ledger_balanced() + + # and then back to cpi adj again, but this time for more than the orig amount + wall1.update( + adjusted_status=WallAdjustedStatus.CPI_ADJUSTMENT, + adjusted_cpi=Decimal("2.00"), + adjusted_timestamp=utc_hour_ago + timedelta(minutes=45), + ) + thl_lm.create_tx_task_adjustment(wall1, user) + session.adjust_status() + thl_lm.create_tx_bp_adjustment(session) + assert 500 - (500 * 0.05) == thl_lm.get_account_balance(bp_wallet_account) + assert 0 == thl_lm.get_account_balance(revenue) + assert 500 * 0.05 == thl_lm.get_account_balance(bp_commission_account) + assert thl_lm.check_ledger_balanced() + + # And adjust again + wall1.update( + adjusted_status=WallAdjustedStatus.CPI_ADJUSTMENT, + adjusted_cpi=Decimal("3.00"), + adjusted_timestamp=utc_hour_ago + timedelta(minutes=45), + ) + thl_lm.create_tx_task_adjustment(wall=wall1, user=user) + session.adjust_status() + thl_lm.create_tx_bp_adjustment(session=session) + assert 600 - (600 * 0.05) == thl_lm.get_account_balance( + account=bp_wallet_account + ) + assert 0 == thl_lm.get_account_balance(account=revenue) + assert 600 * 0.05 == thl_lm.get_account_balance(account=bp_commission_account) + assert thl_lm.check_ledger_balanced() diff --git a/tests/managers/thl/test_ledger/test_thl_lm_tx__user_payouts.py b/tests/managers/thl/test_ledger/test_thl_lm_tx__user_payouts.py new file mode 100644 index 0000000..1e7146a --- /dev/null +++ b/tests/managers/thl/test_ledger/test_thl_lm_tx__user_payouts.py @@ -0,0 +1,505 @@ +import logging +from datetime import datetime, timezone, timedelta +from decimal import Decimal +from uuid import uuid4 + +import pytest + +from generalresearch.managers.thl.ledger_manager.exceptions import ( + LedgerTransactionFlagAlreadyExistsError, + LedgerTransactionConditionFailedError, +) +from generalresearch.models.thl.user import User +from generalresearch.models.thl.wallet import PayoutType +from generalresearch.models.thl.payout import UserPayoutEvent +from test_utils.managers.ledger.conftest import create_main_accounts + + +class TestLedgerManagerAMT: + + def test_create_transaction_amt_ass_request( + self, + user_factory, + product_amt_true, + create_main_accounts, + thl_lm, + lm, + delete_ledger_db, + ): + delete_ledger_db() + create_main_accounts() + user: User = user_factory(product=product_amt_true) + + # debit_account_uuid nothing checks they match the ledger ... todo? + pe = UserPayoutEvent( + uuid=uuid4().hex, + payout_type=PayoutType.AMT_HIT, + amount=5, + cashout_method_uuid=uuid4().hex, + debit_account_uuid=uuid4().hex, + ) + flag_key = f"test:user_payout:{pe.uuid}:request" + flag_name = f"ledger-manager:transaction_flag:{flag_key}" + lm.redis_client.delete(flag_name) + + # User has $0 in their wallet. They are allowed amt_assignment payouts until -$1.00 + thl_lm.create_tx_user_payout_request(user=user, payout_event=pe) + with pytest.raises(expected_exception=LedgerTransactionFlagAlreadyExistsError): + thl_lm.create_tx_user_payout_request( + user=user, payout_event=pe, skip_flag_check=False + ) + with pytest.raises(expected_exception=LedgerTransactionConditionFailedError): + thl_lm.create_tx_user_payout_request( + user=user, payout_event=pe, skip_flag_check=True + ) + pe2 = UserPayoutEvent( + uuid=uuid4().hex, + payout_type=PayoutType.AMT_HIT, + amount=96, + cashout_method_uuid=uuid4().hex, + debit_account_uuid=uuid4().hex, + ) + + flag_key = f"test:user_payout:{pe2.uuid}:request" + flag_name = f"ledger-manager:transaction_flag:{flag_key}" + lm.redis_client.delete(flag_name) + # 96 cents would put them over the -$1.00 limit + with pytest.raises(expected_exception=LedgerTransactionConditionFailedError): + thl_lm.create_tx_user_payout_request(user, payout_event=pe2) + + # But they could do 0.95 cents + pe2.amount = 95 + thl_lm.create_tx_user_payout_request( + user, payout_event=pe2, skip_flag_check=True + ) + + cash = thl_lm.get_account_cash() + bp_wallet_account = thl_lm.get_account_or_create_bp_wallet(user.product) + bp_pending_account = thl_lm.get_or_create_bp_pending_payout_account( + product=user.product + ) + user_wallet_account = thl_lm.get_account_or_create_user_wallet(user=user) + + assert 0 == lm.get_account_balance(account=bp_wallet_account) + assert 0 == lm.get_account_balance(account=cash) + assert 100 == lm.get_account_balance(account=bp_pending_account) + assert -100 == lm.get_account_balance(account=user_wallet_account) + assert thl_lm.check_ledger_balanced() + assert -5 == thl_lm.get_account_filtered_balance( + account=user_wallet_account, + metadata_key="payoutevent", + metadata_value=pe.uuid, + ) + + assert -95 == thl_lm.get_account_filtered_balance( + account=user_wallet_account, + metadata_key="payoutevent", + metadata_value=pe2.uuid, + ) + + def test_create_transaction_amt_ass_complete( + self, + user_factory, + product_amt_true, + create_main_accounts, + thl_lm, + lm, + delete_ledger_db, + ): + delete_ledger_db() + create_main_accounts() + user: User = user_factory(product=product_amt_true) + + pe = UserPayoutEvent( + uuid=uuid4().hex, + payout_type=PayoutType.AMT_HIT, + amount=5, + cashout_method_uuid=uuid4().hex, + debit_account_uuid=uuid4().hex, + ) + flag = f"ledger-manager:transaction_flag:test:user_payout:{pe.uuid}:request" + lm.redis_client.delete(flag) + flag = f"ledger-manager:transaction_flag:test:user_payout:{pe.uuid}:complete" + lm.redis_client.delete(flag) + + # User has $0 in their wallet. They are allowed amt_assignment payouts until -$1.00 + thl_lm.create_tx_user_payout_request(user, payout_event=pe) + thl_lm.create_tx_user_payout_complete(user, payout_event=pe) + + cash = thl_lm.get_account_cash() + bp_wallet_account = thl_lm.get_account_or_create_bp_wallet(user.product) + bp_pending_account = thl_lm.get_or_create_bp_pending_payout_account( + user.product + ) + bp_amt_expense_account = thl_lm.get_account_or_create_bp_expense( + user.product, expense_name="amt" + ) + user_wallet_account = thl_lm.get_account_or_create_user_wallet(user) + + # BP wallet pays the 1cent fee + assert -1 == thl_lm.get_account_balance(bp_wallet_account) + assert -5 == thl_lm.get_account_balance(cash) + assert -1 == thl_lm.get_account_balance(bp_amt_expense_account) + assert 0 == thl_lm.get_account_balance(bp_pending_account) + assert -5 == lm.get_account_balance(user_wallet_account) + assert thl_lm.check_ledger_balanced() + + def test_create_transaction_amt_bonus( + self, + user_factory, + product_amt_true, + create_main_accounts, + thl_lm, + lm, + delete_ledger_db, + ): + delete_ledger_db() + create_main_accounts() + + user: User = user_factory(product=product_amt_true) + + pe = UserPayoutEvent( + uuid=uuid4().hex, + payout_type=PayoutType.AMT_BONUS, + amount=34, + cashout_method_uuid=uuid4().hex, + debit_account_uuid=uuid4().hex, + ) + flag = f"ledger-manager:transaction_flag:test:user_payout:{pe.uuid}:request" + lm.redis_client.delete(flag) + flag = f"ledger-manager:transaction_flag:test:user_payout:{pe.uuid}:complete" + lm.redis_client.delete(flag) + + with pytest.raises(expected_exception=LedgerTransactionConditionFailedError): + # User has $0 in their wallet. No amt bonus allowed + thl_lm.create_tx_user_payout_request(user, payout_event=pe) + + thl_lm.create_tx_user_bonus( + user, + amount=Decimal(5), + ref_uuid="e703830dec124f17abed2d697d8d7701", + description="Bribe", + skip_flag_check=True, + ) + pe.amount = 101 + thl_lm.create_tx_user_payout_request( + user, payout_event=pe, skip_flag_check=False + ) + thl_lm.create_tx_user_payout_complete( + user, payout_event=pe, skip_flag_check=False + ) + with pytest.raises(expected_exception=LedgerTransactionFlagAlreadyExistsError): + # duplicate, even if amount changed + pe.amount = 200 + thl_lm.create_tx_user_payout_complete( + user, payout_event=pe, skip_flag_check=False + ) + with pytest.raises(expected_exception=LedgerTransactionConditionFailedError): + # duplicate + thl_lm.create_tx_user_payout_complete( + user, payout_event=pe, skip_flag_check=True + ) + pe.uuid = "533364150de4451198e5774e221a2acb" + pe.amount = 9900 + with pytest.raises(expected_exception=ValueError): + # Trying to complete payout with no pending tx + thl_lm.create_tx_user_payout_complete( + user, payout_event=pe, skip_flag_check=True + ) + with pytest.raises(expected_exception=LedgerTransactionConditionFailedError): + # trying to payout $99 with only a $5 balance + thl_lm.create_tx_user_payout_request( + user, payout_event=pe, skip_flag_check=True + ) + + cash = thl_lm.get_account_cash() + bp_wallet_account = thl_lm.get_account_or_create_bp_wallet(user.product) + bp_pending_account = thl_lm.get_or_create_bp_pending_payout_account( + user.product + ) + bp_amt_expense_account = thl_lm.get_account_or_create_bp_expense( + user.product, expense_name="amt" + ) + user_wallet_account = thl_lm.get_account_or_create_user_wallet(user) + assert -500 + round(-101 * 0.20) == thl_lm.get_account_balance( + bp_wallet_account + ) + assert -101 == lm.get_account_balance(cash) + assert -20 == lm.get_account_balance(bp_amt_expense_account) + assert 0 == lm.get_account_balance(bp_pending_account) + assert 500 - 101 == lm.get_account_balance(user_wallet_account) + assert lm.check_ledger_balanced() is True + + def test_create_transaction_amt_bonus_cancel( + self, + user_factory, + product_amt_true, + create_main_accounts, + caplog, + thl_lm, + lm, + delete_ledger_db, + ): + delete_ledger_db() + create_main_accounts() + + now = datetime.now(timezone.utc) - timedelta(hours=1) + user: User = user_factory(product=product_amt_true) + + pe = UserPayoutEvent( + uuid=uuid4().hex, + payout_type=PayoutType.AMT_BONUS, + amount=101, + cashout_method_uuid=uuid4().hex, + debit_account_uuid=uuid4().hex, + ) + + thl_lm.create_tx_user_bonus( + user, + amount=Decimal(5), + ref_uuid="c44f4da2db1d421ebc6a5e5241ca4ce6", + description="Bribe", + skip_flag_check=True, + ) + thl_lm.create_tx_user_payout_request( + user, payout_event=pe, skip_flag_check=True + ) + thl_lm.create_tx_user_payout_cancelled( + user, payout_event=pe, skip_flag_check=True + ) + with pytest.raises(expected_exception=LedgerTransactionConditionFailedError): + with caplog.at_level(logging.WARNING): + thl_lm.create_tx_user_payout_complete( + user, payout_event=pe, skip_flag_check=True + ) + assert "trying to complete payout that was already cancelled" in caplog.text + + cash = thl_lm.get_account_cash() + bp_wallet_account = thl_lm.get_account_or_create_bp_wallet(user.product) + bp_pending_account = thl_lm.get_or_create_bp_pending_payout_account( + user.product + ) + bp_amt_expense_account = thl_lm.get_account_or_create_bp_expense( + user.product, expense_name="amt" + ) + user_wallet_account = thl_lm.get_account_or_create_user_wallet(user) + assert -500 == thl_lm.get_account_balance(account=bp_wallet_account) + assert 0 == thl_lm.get_account_balance(account=cash) + assert 0 == thl_lm.get_account_balance(account=bp_amt_expense_account) + assert 0 == thl_lm.get_account_balance(account=bp_pending_account) + assert 500 == thl_lm.get_account_balance(account=user_wallet_account) + assert thl_lm.check_ledger_balanced() + + pe2 = UserPayoutEvent( + uuid=uuid4().hex, + payout_type=PayoutType.AMT_BONUS, + amount=200, + cashout_method_uuid=uuid4().hex, + debit_account_uuid=uuid4().hex, + ) + thl_lm.create_tx_user_payout_request( + user, payout_event=pe2, skip_flag_check=True + ) + thl_lm.create_tx_user_payout_complete( + user, payout_event=pe2, skip_flag_check=True + ) + with pytest.raises(expected_exception=LedgerTransactionConditionFailedError): + with caplog.at_level(logging.WARNING): + thl_lm.create_tx_user_payout_cancelled( + user, payout_event=pe2, skip_flag_check=True + ) + assert "trying to cancel payout that was already completed" in caplog.text + + +class TestLedgerManagerTango: + + def test_create_transaction_tango_request( + self, + user_factory, + product_amt_true, + create_main_accounts, + thl_lm, + lm, + delete_ledger_db, + ): + delete_ledger_db() + create_main_accounts() + + user: User = user_factory(product=product_amt_true) + + # debit_account_uuid nothing checks they match the ledger ... todo? + pe = UserPayoutEvent( + uuid=uuid4().hex, + payout_type=PayoutType.TANGO, + amount=500, + cashout_method_uuid=uuid4().hex, + debit_account_uuid=uuid4().hex, + ) + flag_key = f"test:user_payout:{pe.uuid}:request" + flag_name = f"ledger-manager:transaction_flag:{flag_key}" + lm.redis_client.delete(flag_name) + thl_lm.create_tx_user_bonus( + user, + amount=Decimal(6), + ref_uuid="e703830dec124f17abed2d697d8d7701", + description="Bribe", + skip_flag_check=True, + ) + thl_lm.create_tx_user_payout_request( + user, payout_event=pe, skip_flag_check=True + ) + + cash = thl_lm.get_account_cash() + bp_wallet_account = thl_lm.get_account_or_create_bp_wallet(user.product) + bp_pending_account = thl_lm.get_or_create_bp_pending_payout_account( + user.product + ) + bp_tango_expense_account = thl_lm.get_account_or_create_bp_expense( + user.product, expense_name="tango" + ) + user_wallet_account = thl_lm.get_account_or_create_user_wallet(user) + assert -600 == thl_lm.get_account_balance(bp_wallet_account) + assert 0 == thl_lm.get_account_balance(cash) + assert 0 == thl_lm.get_account_balance(bp_tango_expense_account) + assert 500 == thl_lm.get_account_balance(bp_pending_account) + assert 600 - 500 == thl_lm.get_account_balance(user_wallet_account) + assert thl_lm.check_ledger_balanced() + + thl_lm.create_tx_user_payout_complete( + user, payout_event=pe, skip_flag_check=True + ) + assert -600 - round(500 * 0.035) == thl_lm.get_account_balance( + bp_wallet_account + ) + assert -500, thl_lm.get_account_balance(cash) + assert round(-500 * 0.035) == thl_lm.get_account_balance( + bp_tango_expense_account + ) + assert 0 == lm.get_account_balance(bp_pending_account) + assert 100 == lm.get_account_balance(user_wallet_account) + assert lm.check_ledger_balanced() + + +class TestLedgerManagerPaypal: + + def test_create_transaction_paypal_request( + self, + user_factory, + product_amt_true, + create_main_accounts, + thl_lm, + lm, + delete_ledger_db, + ): + delete_ledger_db() + create_main_accounts() + + now = datetime.now(tz=timezone.utc) - timedelta(hours=1) + user: User = user_factory(product=product_amt_true) + + # debit_account_uuid nothing checks they match the ledger ... todo? + pe = UserPayoutEvent( + uuid=uuid4().hex, + payout_type=PayoutType.PAYPAL, + amount=500, + cashout_method_uuid=uuid4().hex, + debit_account_uuid=uuid4().hex, + ) + flag_key = f"test:user_payout:{pe.uuid}:request" + flag_name = f"ledger-manager:transaction_flag:{flag_key}" + lm.redis_client.delete(flag_name) + thl_lm.create_tx_user_bonus( + user=user, + amount=Decimal(6), + ref_uuid="e703830dec124f17abed2d697d8d7701", + description="Bribe", + skip_flag_check=True, + ) + + thl_lm.create_tx_user_payout_request( + user, payout_event=pe, skip_flag_check=True + ) + + cash = thl_lm.get_account_cash() + bp_wallet_account = thl_lm.get_account_or_create_bp_wallet(user.product) + bp_pending_account = thl_lm.get_or_create_bp_pending_payout_account( + product=user.product + ) + bp_paypal_expense_account = thl_lm.get_account_or_create_bp_expense( + product=user.product, expense_name="paypal" + ) + user_wallet_account = thl_lm.get_account_or_create_user_wallet(user=user) + assert -600 == lm.get_account_balance(account=bp_wallet_account) + assert 0 == lm.get_account_balance(account=cash) + assert 0 == lm.get_account_balance(account=bp_paypal_expense_account) + assert 500 == lm.get_account_balance(account=bp_pending_account) + assert 600 - 500 == lm.get_account_balance(account=user_wallet_account) + assert thl_lm.check_ledger_balanced() + + thl_lm.create_tx_user_payout_complete( + user=user, payout_event=pe, skip_flag_check=True, fee_amount=Decimal("0.50") + ) + assert -600 - 50 == thl_lm.get_account_balance(bp_wallet_account) + assert -500 == thl_lm.get_account_balance(cash) + assert -50 == thl_lm.get_account_balance(bp_paypal_expense_account) + assert 0 == thl_lm.get_account_balance(bp_pending_account) + assert 100 == thl_lm.get_account_balance(user_wallet_account) + assert thl_lm.check_ledger_balanced() + + +class TestLedgerManagerBonus: + + def test_create_transaction_bonus( + self, + user_factory, + product_user_wallet_yes, + create_main_accounts, + thl_lm, + lm, + delete_ledger_db, + ): + delete_ledger_db() + create_main_accounts() + + user: User = user_factory(product=product_user_wallet_yes) + + thl_lm.create_tx_user_bonus( + user=user, + amount=Decimal(5), + ref_uuid="8d0aaf612462448a9ebdd57fab0fc660", + description="Bribe", + skip_flag_check=True, + ) + cash = thl_lm.get_account_cash() + bp_wallet_account = thl_lm.get_account_or_create_bp_wallet(user.product) + bp_pending_account = thl_lm.get_or_create_bp_pending_payout_account( + product=user.product + ) + bp_amt_expense_account = thl_lm.get_account_or_create_bp_expense( + user.product, expense_name="amt" + ) + user_wallet_account = thl_lm.get_account_or_create_user_wallet(user=user) + + assert -500 == lm.get_account_balance(account=bp_wallet_account) + assert 0 == lm.get_account_balance(account=cash) + assert 0 == lm.get_account_balance(account=bp_amt_expense_account) + assert 0 == lm.get_account_balance(account=bp_pending_account) + assert 500 == lm.get_account_balance(account=user_wallet_account) + assert thl_lm.check_ledger_balanced() + + with pytest.raises(expected_exception=LedgerTransactionFlagAlreadyExistsError): + thl_lm.create_tx_user_bonus( + user=user, + amount=Decimal(5), + ref_uuid="8d0aaf612462448a9ebdd57fab0fc660", + description="Bribe", + skip_flag_check=False, + ) + with pytest.raises(expected_exception=LedgerTransactionConditionFailedError): + thl_lm.create_tx_user_bonus( + user=user, + amount=Decimal(5), + ref_uuid="8d0aaf612462448a9ebdd57fab0fc660", + description="Bribe", + skip_flag_check=True, + ) diff --git a/tests/managers/thl/test_ledger/test_thl_pem.py b/tests/managers/thl/test_ledger/test_thl_pem.py new file mode 100644 index 0000000..5fb9e7d --- /dev/null +++ b/tests/managers/thl/test_ledger/test_thl_pem.py @@ -0,0 +1,251 @@ +import uuid +from random import randint +from uuid import uuid4, UUID + +import pytest + +from generalresearch.currency import USDCent +from generalresearch.models.thl.definitions import PayoutStatus +from generalresearch.models.thl.payout import BrokerageProductPayoutEvent +from generalresearch.models.thl.product import Product +from generalresearch.models.thl.wallet.cashout_method import ( + CashoutRequestInfo, +) + + +class TestThlPayoutEventManager: + + def test_get_by_uuid(self, brokerage_product_payout_event_manager, thl_lm): + """This validates that the method raises an exception if it + fails. There are plenty of other tests that use this method so + it seems silly to duplicate it here again + """ + + with pytest.raises(expected_exception=AssertionError) as excinfo: + brokerage_product_payout_event_manager.get_by_uuid(pe_uuid=uuid4().hex) + assert "expected 1 result, got 0" in str(excinfo.value) + + def test_filter_by( + self, + product_factory, + usd_cent, + bp_payout_event_factory, + thl_lm, + brokerage_product_payout_event_manager, + ): + from generalresearch.models.thl.payout import UserPayoutEvent + + N_PRODUCTS = randint(3, 10) + N_PAYOUT_EVENTS = randint(3, 10) + amounts = [] + products = [] + + for x_idx in range(N_PRODUCTS): + product: Product = product_factory() + thl_lm.get_account_or_create_bp_wallet(product=product) + products.append(product) + brokerage_product_payout_event_manager.set_account_lookup_table( + thl_lm=thl_lm + ) + + for y_idx in range(N_PAYOUT_EVENTS): + pe = bp_payout_event_factory(product=product, usd_cent=usd_cent) + amounts.append(int(usd_cent)) + assert isinstance(pe, BrokerageProductPayoutEvent) + + # We just added Payout Events for Products, now go ahead and + # query for them + accounts = thl_lm.get_accounts_bp_wallet_for_products( + product_uuids=[i.uuid for i in products] + ) + res = brokerage_product_payout_event_manager.filter_by( + debit_account_uuids=[i.uuid for i in accounts] + ) + + assert len(res) == (N_PRODUCTS * N_PAYOUT_EVENTS) + assert sum([i.amount for i in res]) == sum(amounts) + + def test_get_bp_payout_events_for_product( + self, + product_factory, + usd_cent, + bp_payout_event_factory, + brokerage_product_payout_event_manager, + thl_lm, + ): + from generalresearch.models.thl.payout import UserPayoutEvent + + N_PRODUCTS = randint(3, 10) + N_PAYOUT_EVENTS = randint(3, 10) + amounts = [] + products = [] + + for x_idx in range(N_PRODUCTS): + product: Product = product_factory() + products.append(product) + thl_lm.get_account_or_create_bp_wallet(product=product) + brokerage_product_payout_event_manager.set_account_lookup_table( + thl_lm=thl_lm + ) + + for y_idx in range(N_PAYOUT_EVENTS): + pe = bp_payout_event_factory(product=product, usd_cent=usd_cent) + amounts.append(usd_cent) + assert isinstance(pe, BrokerageProductPayoutEvent) + + # We just added 5 Payouts for a specific Product, now go + # ahead and query for them + res = brokerage_product_payout_event_manager.get_bp_bp_payout_events_for_products( + thl_ledger_manager=thl_lm, product_uuids=[product.id] + ) + + assert len(res) == N_PAYOUT_EVENTS + + # Now that all the Payouts for all the Products have been added, go + # ahead and query for them + res = ( + brokerage_product_payout_event_manager.get_bp_bp_payout_events_for_products( + thl_ledger_manager=thl_lm, product_uuids=[i.uuid for i in products] + ) + ) + + assert len(res) == (N_PRODUCTS * N_PAYOUT_EVENTS) + assert sum([i.amount for i in res]) == sum(amounts) + + @pytest.mark.skip + def test_get_payout_detail(self, user_payout_event_manager): + """This fails because the description coming back is None, but then + it tries to return a PayoutEvent which validates that the + description can't be None + """ + from generalresearch.models.thl.payout import ( + UserPayoutEvent, + PayoutType, + ) + + rand_amount = randint(a=99, b=999) + + pe = user_payout_event_manager.create( + debit_account_uuid=uuid4().hex, + account_reference_type="str-type-random", + account_reference_uuid=uuid4().hex, + cashout_method_uuid=uuid4().hex, + description="Best payout !", + amount=rand_amount, + status=PayoutStatus.PENDING, + ext_ref_id="123", + payout_type=PayoutType.CASH_IN_MAIL, + request_data={"foo": 123}, + order_data={}, + ) + + res = user_payout_event_manager.get_payout_detail(pe_uuid=pe.uuid) + assert isinstance(res, CashoutRequestInfo) + + # def test_filter_by(self): + # raise NotImplementedError + + def test_create(self, user_payout_event_manager): + from generalresearch.models.thl.payout import UserPayoutEvent + + # Confirm the creation method returns back an instance. + pe = user_payout_event_manager.create_dummy() + assert isinstance(pe, UserPayoutEvent) + + # Now query the DB for that PayoutEvent to confirm it was actually + # saved. + res = user_payout_event_manager.get_by_uuid(pe_uuid=pe.uuid) + assert isinstance(res, UserPayoutEvent) + assert UUID(res.uuid) + + # Confirm they're the same + # assert pe.model_dump_json() == res2.model_dump_json() + assert res.description is None + + # def test_update(self): + # raise NotImplementedError + + def test_create_bp_payout( + self, + product, + delete_ledger_db, + create_main_accounts, + thl_lm, + brokerage_product_payout_event_manager, + lm, + ): + from generalresearch.models.thl.payout import UserPayoutEvent + + delete_ledger_db() + create_main_accounts() + + account_bp_wallet = thl_lm.get_account_or_create_bp_wallet(product=product) + brokerage_product_payout_event_manager.set_account_lookup_table(thl_lm=thl_lm) + + rand_amount = randint(a=99, b=999) + + # Save a Brokerage Product Payout, so we have something in the + # Payout Event table and the respective ledger TX and Entry rows for it + pe = brokerage_product_payout_event_manager.create_bp_payout_event( + thl_ledger_manager=thl_lm, + product=product, + amount=USDCent(rand_amount), + skip_wallet_balance_check=True, + skip_one_per_day_check=True, + ) + assert isinstance(pe, BrokerageProductPayoutEvent) + + # Now try to query for it! + res = thl_lm.get_tx_bp_payouts(account_uuids=[account_bp_wallet.uuid]) + assert len(res) == 1 + res = thl_lm.get_tx_bp_payouts(account_uuids=[uuid4().hex]) + assert len(res) == 0 + + # Confirm it added to the users balance. The amount is negative because + # money was sent to the Brokerage Product, but they didn't have + # any activity that earned them money + bal = lm.get_account_balance(account=account_bp_wallet) + assert rand_amount == bal * -1 + + +class TestBPPayoutEvent: + + def test_get_bp_bp_payout_events_for_products( + self, + product_factory, + bp_payout_event_factory, + usd_cent, + delete_ledger_db, + create_main_accounts, + brokerage_product_payout_event_manager, + thl_lm, + ): + delete_ledger_db() + create_main_accounts() + + N_PAYOUT_EVENTS = randint(3, 10) + amounts = [] + + product: Product = product_factory() + thl_lm.get_account_or_create_bp_wallet(product=product) + brokerage_product_payout_event_manager.set_account_lookup_table(thl_lm=thl_lm) + + for y_idx in range(N_PAYOUT_EVENTS): + bp_payout_event_factory(product=product, usd_cent=usd_cent) + amounts.append(usd_cent) + + # Fetch using the _bp_bp_ approach, so we have an + # array of BPPayoutEvents + bp_bp_res = ( + brokerage_product_payout_event_manager.get_bp_bp_payout_events_for_products( + thl_ledger_manager=thl_lm, product_uuids=[product.uuid] + ) + ) + assert isinstance(bp_bp_res, list) + assert sum(amounts) == sum([i.amount for i in bp_bp_res]) + for i in bp_bp_res: + assert isinstance(i, BrokerageProductPayoutEvent) + assert isinstance(i.amount, int) + assert isinstance(i.amount_usd, USDCent) + assert isinstance(i.amount_usd_str, str) + assert i.amount_usd_str[0] == "$" diff --git a/tests/managers/thl/test_ledger/test_user_txs.py b/tests/managers/thl/test_ledger/test_user_txs.py new file mode 100644 index 0000000..d81b244 --- /dev/null +++ b/tests/managers/thl/test_ledger/test_user_txs.py @@ -0,0 +1,288 @@ +from datetime import timedelta, datetime, timezone +from decimal import Decimal +from uuid import uuid4 + +from generalresearch.managers.thl.payout import ( + AMT_ASSIGNMENT_CASHOUT_METHOD, + AMT_BONUS_CASHOUT_METHOD, +) +from generalresearch.managers.thl.user_compensate import user_compensate +from generalresearch.models.thl.definitions import ( + Status, + WallAdjustedStatus, +) +from generalresearch.models.thl.ledger import ( + UserLedgerTransactionTypesSummary, + UserLedgerTransactionTypeSummary, + TransactionType, +) +from generalresearch.models.thl.session import Session +from generalresearch.models.thl.user import User +from generalresearch.models.thl.wallet import PayoutType + + +def test_user_txs( + user_factory, + product_amt_true, + create_main_accounts, + thl_lm, + lm, + delete_ledger_db, + session_with_tx_factory, + adj_to_fail_with_tx_factory, + adj_to_complete_with_tx_factory, + session_factory, + user_payout_event_manager, + utc_now, +): + delete_ledger_db() + create_main_accounts() + + user: User = user_factory(product=product_amt_true) + account = thl_lm.get_account_or_create_user_wallet(user) + print(f"{account.uuid=}") + + s: Session = session_with_tx_factory(user=user, wall_req_cpi=Decimal("1.00")) + + bribe_uuid = user_compensate( + ledger_manager=thl_lm, + user=user, + amount_int=100, + ) + + pe = user_payout_event_manager.create( + uuid=uuid4().hex, + debit_account_uuid=account.uuid, + cashout_method_uuid=AMT_ASSIGNMENT_CASHOUT_METHOD, + amount=5, + created=utc_now, + payout_type=PayoutType.AMT_HIT, + request_data=dict(), + ) + thl_lm.create_tx_user_payout_request( + user=user, + payout_event=pe, + ) + pe = user_payout_event_manager.create( + uuid=uuid4().hex, + debit_account_uuid=account.uuid, + cashout_method_uuid=AMT_BONUS_CASHOUT_METHOD, + amount=127, + created=utc_now, + payout_type=PayoutType.AMT_BONUS, + request_data=dict(), + ) + thl_lm.create_tx_user_payout_request( + user=user, + payout_event=pe, + ) + + wall = s.wall_events[-1] + adj_to_fail_with_tx_factory(session=s, created=wall.finished) + + # And a fail -> complete adjustment + s_fail: Session = session_factory( + user=user, + wall_count=1, + final_status=Status.FAIL, + wall_req_cpi=Decimal("2.00"), + ) + adj_to_complete_with_tx_factory(session=s_fail, created=utc_now) + + # txs = thl_lm.get_tx_filtered_by_account(account.uuid) + # print(len(txs), txs) + txs = thl_lm.get_user_txs(user) + assert len(txs.transactions) == 6 + assert txs.total == 6 + assert txs.page == 1 + assert txs.size == 50 + + # print(len(txs.transactions), txs) + d = txs.model_dump_json() + # print(d) + + descriptions = {x.description for x in txs.transactions} + assert descriptions == { + "Compensation Bonus", + "HIT Bonus", + "HIT Reward", + "Task Adjustment", + "Task Complete", + } + amounts = {x.amount for x in txs.transactions} + assert amounts == {-127, 100, 38, -38, -5, 76} + + assert txs.summary == UserLedgerTransactionTypesSummary( + bp_adjustment=UserLedgerTransactionTypeSummary( + entry_count=2, min_amount=-38, max_amount=76, total_amount=76 - 38 + ), + bp_payment=UserLedgerTransactionTypeSummary( + entry_count=1, min_amount=38, max_amount=38, total_amount=38 + ), + user_bonus=UserLedgerTransactionTypeSummary( + entry_count=1, min_amount=100, max_amount=100, total_amount=100 + ), + user_payout_request=UserLedgerTransactionTypeSummary( + entry_count=2, min_amount=-127, max_amount=-5, total_amount=-132 + ), + ) + tx_adj_c = [ + tx for tx in txs.transactions if tx.tx_type == TransactionType.BP_ADJUSTMENT + ] + assert sorted([tx.amount for tx in tx_adj_c]) == [-38, 76] + + +def test_user_txs_pagination( + user_factory, + product_amt_true, + create_main_accounts, + thl_lm, + lm, + delete_ledger_db, + session_with_tx_factory, + adj_to_fail_with_tx_factory, + user_payout_event_manager, + utc_now, +): + delete_ledger_db() + create_main_accounts() + + user: User = user_factory(product=product_amt_true) + account = thl_lm.get_account_or_create_user_wallet(user) + print(f"{account.uuid=}") + + for _ in range(12): + user_compensate( + ledger_manager=thl_lm, + user=user, + amount_int=100, + skip_flag_check=True, + ) + + txs = thl_lm.get_user_txs(user, page=1, size=5) + assert len(txs.transactions) == 5 + assert txs.total == 12 + assert txs.page == 1 + assert txs.size == 5 + assert txs.summary.user_bonus.total_amount == 1200 + assert txs.summary.user_bonus.entry_count == 12 + + # Skip to the 3rd page. We made 12, so there are 2 left + txs = thl_lm.get_user_txs(user, page=3, size=5) + assert len(txs.transactions) == 2 + assert txs.total == 12 + assert txs.page == 3 + assert txs.summary.user_bonus.total_amount == 1200 + assert txs.summary.user_bonus.entry_count == 12 + + # Should be empty, not fail + txs = thl_lm.get_user_txs(user, page=4, size=5) + assert len(txs.transactions) == 0 + assert txs.total == 12 + assert txs.page == 4 + assert txs.summary.user_bonus.total_amount == 1200 + assert txs.summary.user_bonus.entry_count == 12 + + # Test filtering. We should pull back only this one + now = datetime.now(tz=timezone.utc) + user_compensate( + ledger_manager=thl_lm, + user=user, + amount_int=100, + skip_flag_check=True, + ) + txs = thl_lm.get_user_txs(user, page=1, size=5, time_start=now) + assert len(txs.transactions) == 1 + assert txs.total == 1 + assert txs.page == 1 + # And the summary is restricted to this time range also! + assert txs.summary.user_bonus.total_amount == 100 + assert txs.summary.user_bonus.entry_count == 1 + + # And filtering with 0 results + now = datetime.now(tz=timezone.utc) + txs = thl_lm.get_user_txs(user, page=1, size=5, time_start=now) + assert len(txs.transactions) == 0 + assert txs.total == 0 + assert txs.page == 1 + assert txs.pages == 0 + # And the summary is restricted to this time range also! + assert txs.summary.user_bonus.total_amount == None + assert txs.summary.user_bonus.entry_count == 0 + + +def test_user_txs_rolling_balance( + user_factory, + product_amt_true, + create_main_accounts, + thl_lm, + lm, + delete_ledger_db, + session_with_tx_factory, + adj_to_fail_with_tx_factory, + user_payout_event_manager, +): + """ + Creates 3 $1.00 bonuses (postive), + then 1 cashout (negative), $1.50 + then 3 more $1.00 bonuses. + Note: pagination + rolling balance will BREAK if txs have + identical timestamps. In practice, they do not. + """ + delete_ledger_db() + create_main_accounts() + + user: User = user_factory(product=product_amt_true) + account = thl_lm.get_account_or_create_user_wallet(user) + + for _ in range(3): + user_compensate( + ledger_manager=thl_lm, + user=user, + amount_int=100, + skip_flag_check=True, + ) + pe = user_payout_event_manager.create( + uuid=uuid4().hex, + debit_account_uuid=account.uuid, + cashout_method_uuid=AMT_BONUS_CASHOUT_METHOD, + amount=150, + payout_type=PayoutType.AMT_BONUS, + request_data=dict(), + ) + thl_lm.create_tx_user_payout_request( + user=user, + payout_event=pe, + ) + for _ in range(3): + user_compensate( + ledger_manager=thl_lm, + user=user, + amount_int=100, + skip_flag_check=True, + ) + + txs = thl_lm.get_user_txs(user, page=1, size=10) + assert txs.transactions[0].balance_after == 100 + assert txs.transactions[1].balance_after == 200 + assert txs.transactions[2].balance_after == 300 + assert txs.transactions[3].balance_after == 150 + assert txs.transactions[4].balance_after == 250 + assert txs.transactions[5].balance_after == 350 + assert txs.transactions[6].balance_after == 450 + + # Ascending order, get 2nd page, make sure the balances include + # the previous txs. (will return last 3 txs) + txs = thl_lm.get_user_txs(user, page=2, size=4) + assert len(txs.transactions) == 3 + assert txs.transactions[0].balance_after == 250 + assert txs.transactions[1].balance_after == 350 + assert txs.transactions[2].balance_after == 450 + + # Descending order, get 1st page. Will + # return most recent 3 txs in desc order + txs = thl_lm.get_user_txs(user, page=1, size=3, order_by="-created") + assert len(txs.transactions) == 3 + assert txs.transactions[0].balance_after == 450 + assert txs.transactions[1].balance_after == 350 + assert txs.transactions[2].balance_after == 250 diff --git a/tests/managers/thl/test_ledger/test_wallet.py b/tests/managers/thl/test_ledger/test_wallet.py new file mode 100644 index 0000000..a0abd7c --- /dev/null +++ b/tests/managers/thl/test_ledger/test_wallet.py @@ -0,0 +1,78 @@ +from decimal import Decimal +from uuid import uuid4 + +import pytest + +from generalresearch.models.thl.product import ( + UserWalletConfig, + PayoutConfig, + PayoutTransformation, + PayoutTransformationPercentArgs, +) +from generalresearch.models.thl.user import User + + +@pytest.fixture() +def schrute_product(product_manager): + return product_manager.create_dummy( + user_wallet_config=UserWalletConfig(enabled=True, amt=False), + payout_config=PayoutConfig( + payout_transformation=PayoutTransformation( + f="payout_transformation_percent", + kwargs=PayoutTransformationPercentArgs(pct=0.4), + ), + payout_format="{payout:,.0f} Schrute Bucks", + ), + ) + + +class TestGetUserWalletBalance: + def test_get_user_wallet_balance_non_managed(self, user, thl_lm): + with pytest.raises( + AssertionError, + match="Can't get wallet balance on non-managed account.", + ): + thl_lm.get_user_wallet_balance(user=user) + + def test_get_user_wallet_balance_managed_0( + self, schrute_product, user_factory, thl_lm + ): + assert ( + schrute_product.payout_config.payout_format == "{payout:,.0f} Schrute Bucks" + ) + user: User = user_factory(schrute_product) + balance = thl_lm.get_user_wallet_balance(user=user) + assert balance == 0 + balance_string = user.product.format_payout_format(Decimal(balance) / 100) + assert balance_string == "0 Schrute Bucks" + redeemable_balance = thl_lm.get_user_redeemable_wallet_balance( + user=user, user_wallet_balance=balance + ) + assert redeemable_balance == 0 + redeemable_balance_string = user.product.format_payout_format( + Decimal(redeemable_balance) / 100 + ) + assert redeemable_balance_string == "0 Schrute Bucks" + + def test_get_user_wallet_balance_managed( + self, schrute_product, user_factory, thl_lm, session_with_tx_factory + ): + user: User = user_factory(schrute_product) + thl_lm.create_tx_user_bonus( + user=user, + amount=Decimal(1), + ref_uuid=uuid4().hex, + description="cheese", + ) + session_with_tx_factory(user=user, wall_req_cpi=Decimal("1.23")) + + # This product has a payout xform of 40% and commission of 5% + # 1.23 * 0.05 = 0.06 of commission + # 1.17 of payout * 0.40 = 0.47 of user pay and (1.17-0.47) 0.70 bp pay + balance = thl_lm.get_user_wallet_balance(user=user) + assert balance == 47 + 100 # plus the $1 bribe + + redeemable_balance = thl_lm.get_user_redeemable_wallet_balance( + user=user, user_wallet_balance=balance + ) + assert redeemable_balance == 20 + 100 |
