diff options
| author | stuppie | 2026-08-20 13:33:02 -0600 |
|---|---|---|
| committer | stuppie | 2026-08-20 13:33:02 -0600 |
| commit | 5adb196b4bfb2ddf6959c00f33053fd85a4dff7a (patch) | |
| tree | ed9560a16dc8450591912b75ceb04ced0fa7156f | |
| parent | e397f8d0938a73e5879d39a1ea50904b92b9c279 (diff) | |
| download | generalresearch-5adb196b4bfb2ddf6959c00f33053fd85a4dff7a.tar.gz generalresearch-5adb196b4bfb2ddf6959c00f33053fd85a4dff7a.zip | |
BusinessPayoutEventManager: validation, resume, simplify get and filter
| -rw-r--r-- | generalresearch/managers/thl/payout.py | 379 | ||||
| -rw-r--r-- | generalresearch/models/gr/business.py | 1 | ||||
| -rw-r--r-- | generalresearch/models/thl/payout.py | 64 | ||||
| -rw-r--r-- | generalresearch/models/thl/product.py | 1 | ||||
| -rw-r--r-- | tests/managers/thl/test_payout.py | 131 |
5 files changed, 254 insertions, 322 deletions
diff --git a/generalresearch/managers/thl/payout.py b/generalresearch/managers/thl/payout.py index 4b08be8..8f5b256 100644 --- a/generalresearch/managers/thl/payout.py +++ b/generalresearch/managers/thl/payout.py @@ -398,37 +398,23 @@ class BrokerageProductPayoutEventManager(PayoutEventManager): def get_by_uuid( self, pe_uuid: UUIDStr, - # --- Support resources --- - account_product_mapping: dict[UUIDStr, UUIDStr] | None = None, ) -> BrokerageProductPayoutEvent: res = self.pg_config.execute_sql_query( query=""" - SELECT ep.uuid, - ep.debit_account_uuid, - ep.cashout_method_uuid, + SELECT ep.uuid, ep.debit_account_uuid, ep.cashout_method_uuid, ep.created, ep.amount, ep.status, ep.ext_ref_id, ep.payout_type, ep.request_data::jsonb, - ep.order_data::jsonb + ep.order_data::jsonb, + la.reference_uuid as product_id FROM event_payout AS ep + JOIN ledger_account la on la.uuid = debit_account_uuid WHERE ep.uuid = %s """, params=[pe_uuid], ) assert len(res) == 1, f"{pe_uuid} expected 1 result, got {len(res)}" - - d = res[0] - - # This isn't really need for creation... but we're doing it so that - # it can return back a full BrokerageProductPayoutEvent instance - if account_product_mapping is None: - rc = self.redis_client - account_product_mapping: dict = rc.hgetall(name="pem:account_to_product") - assert isinstance(account_product_mapping, dict) - - d["product_id"] = account_product_mapping[d["debit_account_uuid"]] - - return BrokerageProductPayoutEvent.model_validate(d) + return BrokerageProductPayoutEvent.model_validate(res[0]) @staticmethod def check_for_ledger_tx( @@ -478,112 +464,78 @@ class BrokerageProductPayoutEventManager(PayoutEventManager): def filter_by( self, - reference_uuid: str | None = None, ext_ref_id: str | None = None, debit_account_uuids: Collection[UUIDStr] | None = None, amount: int | None = None, created: datetime | None = None, created_after: datetime | None = None, - product_ids: str | None = None, - bp_user_ids: Collection[str] | None = None, + product_ids: Collection[str] | None = None, cashout_types: Collection[PayoutType] | None = None, statuses: Collection[PayoutStatus] | None = None, ) -> list[BrokerageProductPayoutEvent]: - """Try to retrieve payout events by the product_id/user_uuid, amount, - and optionally timestamp. + """Try to retrieve BP payout events. WARNING: This is only on the "payout events" table and nothing to - do with the Ledger itself. Therefore, the product_ids query - doesn't return Brokerage Product Payouts (the ACH or Wire events - to Suppliers) as part of the query. + do with the Ledger itself - *** IT IS ONLY FOR USER PAYOUTS *** - - Note: what used to be in thl-grpcs "ListCashoutRequests" calling - "list_cashout_requests" was merged into this. + *** IT IS ONLY FOR Brokerage Product PAYOUTS *** """ - args = [] + params = dict() filters = [] - if reference_uuid: - # This could be a product_id or a user_uuid - filters.append("la.reference_uuid = %s") - args.append(reference_uuid) if ext_ref_id: - # This is transaction id for tracking ACH/Wires with a banking - # institution - filters.append("ep.ext_ref_id = %s") - args.append(ext_ref_id) + # This is transaction id for tracking ACH/Wires with a banking institution + filters.append("ep.ext_ref_id = %(ext_ref_id)s") + params["ext_ref_id"] = ext_ref_id if debit_account_uuids: - # Or we could use the bp_wallet or user_wallet's account uuid - # instead of looking up by the product/user - filters.append("ep.debit_account_uuid = ANY(%s)") - args.append(debit_account_uuids) + # Or we could use the bp_wallet's account uuid + # instead of looking up by the product + filters.append("ep.debit_account_uuid = ANY(%(debit_account_uuids)s)") + params["debit_account_uuids"] = debit_account_uuids if amount: - filters.append("ep.amount = %s") - args.append(amount) + filters.append("ep.amount = %(amount)s") + params["amount"] = amount if created: - filters.append("ep.created = %s") - args.append(created.replace(tzinfo=None)) + filters.append("ep.created = %(created)s") + params["created"] = created if created_after: - filters.append("ep.created >= %s") - args.append(created_after.replace(tzinfo=None)) - if product_ids: - filters.append("product_id = ANY(%s)") - args.append(product_ids) - if bp_user_ids: - filters.append("product_user_id = ANY(%s)") - args.append(bp_user_ids) - if cashout_types: - filters.append("payout_type = ANY(%s)") - args.append([x.value for x in cashout_types]) - if statuses: - filters.append("status = ANY(%s)") - args.append([x.value for x in statuses]) + filters.append("ep.created >= %(created_after)s") + params["created_after"] = created_after + if product_ids is not None: + filters.append("la.reference_uuid = ANY(%(product_ids)s)") + params["product_ids"] = product_ids + if cashout_types is not None: + filters.append("payout_type = ANY(%(cashout_types)s)") + params["cashout_types"] = [x.value for x in cashout_types] + if statuses is not None: + filters.append("status = ANY(%(statuses)s)") + params["statuses"] = [x.value for x in statuses] assert len(filters) > 0, "must pass at least 1 filter" filter_str = " AND ".join(filters) + params["cashout_method_uuid"] = self.CASHOUT_METHOD_UUID res = self.pg_config.execute_sql_query( query=f""" - SELECT ep.uuid, - ep.debit_account_uuid, - ep.cashout_method_uuid, - ep.created, - ep.amount, ep.status, ep.ext_ref_id, ep.payout_type, + SELECT ep.uuid, ep.debit_account_uuid, ep.cashout_method_uuid, + ep.created, ep.amount, ep.status, ep.ext_ref_id, + ep.payout_type, ep.supplier_payout_id, ep.request_data::jsonb, ep.order_data::jsonb, ac.name as description, - la.reference_type as account_reference_type, - la.reference_uuid as account_reference_uuid + la.reference_uuid as product_id FROM event_payout AS ep LEFT JOIN accounting_cashoutmethod AS ac ON ep.cashout_method_uuid = ac.id LEFT JOIN ledger_account AS la ON la.uuid = ep.debit_account_uuid - LEFT JOIN thl_user u - ON la.reference_uuid = u.uuid - WHERE cashout_method_uuid = '{self.CASHOUT_METHOD_UUID}' + WHERE cashout_method_uuid = %(cashout_method_uuid)s + AND la.reference_type = 'bp' AND {filter_str} """, - params=args, + params=params, ) - - rc = self.redis_client - account_product_mapping = rc.hgetall(name="pem:account_to_product") - pes = [] - for d in res: - for k in [ - "uuid", - "debit_account_uuid", - "account_reference_uuid", - "cashout_method_uuid", - ]: - if d[k] is not None: - d[k] = UUID(d[k]).hex - - d["product_id"] = account_product_mapping[d["debit_account_uuid"]] - pes.append(BrokerageProductPayoutEvent.model_validate(d)) - + for row in res: + pes.append(BrokerageProductPayoutEvent.model_validate(row)) return pes def get_bp_payout_events_for_accounts( @@ -596,53 +548,38 @@ class BrokerageProductPayoutEventManager(PayoutEventManager): def get_bp_bp_payout_events_for_products( self, - thl_ledger_manager: ThlLedgerManager, product_uuids: Collection[UUIDStr], order_by: OrderBy | None = OrderBy.ASC, ) -> list[BrokerageProductPayoutEvent]: """This is a terrible name, but it returns the BPPayoutEvent model type rather than a list of PayoutEvents. - We do this for the Supplier centric APIs where they don't know, + We do this for the Supplier-centric APIs where they don't know or care about the underlying ledger account structure. """ assert len(product_uuids) > 0, "Must provide product_uuids" - accounts = thl_ledger_manager.get_accounts_bp_wallet_for_products( - product_uuids=product_uuids - ) - - assert len(accounts) == len(product_uuids), "Unequal Product & Account lists" - - rc = self.redis_client - account_product_mapping = rc.hgetall(name="pem:account_to_product") + order_by = order_by or OrderBy.ASC - payout_events: list[BrokerageProductPayoutEvent] = ( - self.get_bp_payout_events_for_accounts( - accounts=accounts, - ) + payout_events = self.filter_by( + product_ids=product_uuids, + cashout_types=[PayoutType.ACH], ) - - return BrokerageProductPayoutEvent.from_payout_events( - payout_events=payout_events, - account_product_mapping=account_product_mapping, - order_by=order_by, + payout_events = sorted( + payout_events, key=lambda x: x.created, reverse=order_by == OrderBy.DESC ) + return payout_events def retry_create_bp_payout_event_tx( self, thl_ledger_manager: ThlLedgerManager, product: Product, - payout_event_uuid: UUIDStr, - skip_wallet_balance_check: bool = False, - skip_one_per_day_check: bool = False, + bp_pe: BrokerageProductPayoutEvent, ) -> BrokerageProductPayoutEvent: """ If a create_bp_payout_event call fails, this can be called with the associated payoutevent. """ - bp_pe: BrokerageProductPayoutEvent = self.get_by_uuid(payout_event_uuid) assert bp_pe.status == PayoutStatus.FAILED, "Only use this on failed payouts" - created = bp_pe.created assert not self.check_for_ledger_tx( thl_ledger_manager=thl_ledger_manager, @@ -655,10 +592,8 @@ class BrokerageProductPayoutEventManager(PayoutEventManager): thl_ledger_manager=thl_ledger_manager, bp_pe=bp_pe, product=product, - amount=bp_pe.amount_usd, - created=created, - skip_one_per_day_check=skip_one_per_day_check, - skip_wallet_balance_check=skip_wallet_balance_check, + skip_one_per_day_check=True, + skip_wallet_balance_check=True, ) def create_pending_bp_payout_events( @@ -763,134 +698,102 @@ class BrokerageProductPayoutEventManager(PayoutEventManager): class BusinessPayoutEventManager(BrokerageProductPayoutEventManager): - def update_ext_reference_ids( - self, - new_value: str, - current_value: str | None = None, - ) -> None: - """ - There are scenarios where an ACH/Wire payout event was saved with - a generic or anonymized reference identifier. We may want to be - able to go back and update all of those transaction IDs. + def get_by_ext_ref_id(self, ext_ref_id: str) -> BusinessPayoutEvent: + res = self.pg_config.execute_sql_query( + """ + SELECT + sp.*, + ep.bp_payouts + FROM supplier_payout sp + JOIN ( + SELECT + ep_inner.supplier_payout_id, + jsonb_agg( + to_jsonb(ep_inner) + || jsonb_build_object('product_id', la.reference_uuid) + ORDER BY ep_inner.created + ) AS bp_payouts + FROM event_payout ep_inner + JOIN ledger_account la + ON ep_inner.debit_account_uuid = la.uuid + GROUP BY ep_inner.supplier_payout_id + ) ep ON sp.id = ep.supplier_payout_id + WHERE sp.ext_ref_id = %(ext_ref_id)s + """, + {"ext_ref_id": ext_ref_id}, + ) + assert len(res) == 1, f"No Business Payout found with ext ref: {ext_ref_id}" + d = res[0] + for bp_payout in d["bp_payouts"]: + bp_payout["created"] = datetime.fromisoformat(bp_payout["created"]) + bpe = BusinessPayoutEvent.model_validate(d) + assert ( + bpe.bp_payouts is not None and len(bpe.bp_payouts) > 0 + ), "No BP payouts found for this Business Payout Event. This shouldn't happen!" + return bpe + def validate_business_payout_in_ledger( + self, ext_ref_id: str, thl_lm: ThlLedgerManager + ): """ - - if current_value is None: - raise ValueError("Dangerous to do ambiguous updates") - - # SELECT first to check that records exist - res = self.filter_by(ext_ref_id=current_value) - if len(res) == 0: - raise Warning("No event_payouts found to UPDATE") - - # As of 2025, no single Business has more than 10,000 Products, - # leave the limit in as an additional safeguard. - query = """ - UPDATE event_payout - SET ext_ref_id = %s - WHERE ext_ref_id = %s + Check that there exist ledger TXs for the BP payouts """ - with self.pg_config.make_connection() as conn: - with conn.cursor() as c: - c.execute(query=query, params=[new_value, current_value]) - assert c.rowcount < 10000 - conn.commit() + bpe = self.get_by_ext_ref_id(ext_ref_id=ext_ref_id) + account_uuids = [bp_pe.debit_account_uuid for bp_pe in bpe.bp_payouts] + txs = thl_lm.get_tx_bp_payouts(account_uuids=account_uuids) + assert len(txs) == len( + bpe.bp_payouts + ), f"Expected {len(bpe.bp_payouts)} BP payouts but found {len(txs)}!" + return True - def delete_failed_business_payout(self, ext_ref_id: str, thl_lm: ThlLedgerManager): + def resume_failed_business_payout( + self, ext_ref_id: str, thl_lm: ThlLedgerManager, pm: ProductManager + ): """ - Sometimes ACH/Wire payouts fail due to multiple reasons (timeouts, - Business Product having insufficient funds, etc). This is a utility - method that finds all event_payouts, and deletes them with all the - associated: - (1) Transactions - (2) Transaction Metadata - (3) Transaction Entries - - and then proceeds to delete them all in reverse order (so there is - no orphan / FK constraint issues). + Sometimes a business payout's BP payouts fail due to multiple reasons + (timeouts, BP having insufficient funds, etc). Grab the PENDING + BP payout events and retry them. """ + bpe = self.get_by_ext_ref_id(ext_ref_id=ext_ref_id) + assert bpe.id + assert bpe.bp_payouts - # (1) Find all by payout_event - event_payouts = self.filter_by(ext_ref_id=ext_ref_id) - if len(event_payouts) == 0: - raise Warning("No event_payouts found to DELETE") - - # sum([i["amount"] for i in event_payouts])/100 - event_payout_uuids = [i.uuid for i in event_payouts] - - # (2) Find all ledger_transactions - tags = [f"{thl_lm.currency.value}:bp_payout:{x}" for x in event_payout_uuids] - transactions = thl_lm.get_txs_by_tags(tags=tags) - transaction_ids = [tx.id for tx in transactions] - print("XXX1", transaction_ids) - # assert len(tags) == len(transactions) - - # (3) Find all ledger_transactionmetadata: assert two rows per tx - tx_metadata_ids = thl_lm.get_tx_metadata_ids_by_txs(transactions=transactions) - # assert len(tx_metadata) == len(transaction_ids)*2 - - # (4) Find all ledger_entry: assert two rows per tx - tx_entries = thl_lm.get_tx_entries_by_txs(transactions=transactions) - tx_entry_ids = [tx_entry.id for tx_entry in tx_entries] - # assert len(tx_entry) == len(transaction_ids)*2 - - # (5) Delete records (all in 1 tx) - with self.pg_config.make_connection() as conn: - with conn.cursor() as c: - # DELETE: tx_entry - c.execute( - """ - DELETE - FROM ledger_entry - WHERE transaction_id = ANY(%s) - AND id = ANY(%s) - """, - [transaction_ids, tx_entry_ids], - ) - - # DELETE: tx_metadata - c.execute( - """ - DELETE - FROM ledger_transactionmetadata - WHERE transaction_id = ANY(%s) - AND id = ANY(%s) - """, - [transaction_ids, list(tx_metadata_ids)], + if bpe.status == PayoutStatus.COMPLETE and all( + bp_pe.status == PayoutStatus.COMPLETE for bp_pe in bpe.bp_payouts + ): + try: + self.validate_business_payout_in_ledger( + ext_ref_id=ext_ref_id, thl_lm=thl_lm ) - - # DELETE: transactions - c.execute( - """ - DELETE - FROM ledger_transaction - WHERE id = ANY(%s) - """, - [transaction_ids], + except AssertionError as e: + LOG.error( + f"Business Payout Event {ext_ref_id} is COMPLETE but BP payouts are not in the ledger! {e}" ) + raise e + LOG.warning( + "Nothing to do! Business Payout is COMPLETE and all Brokerage Product payouts are also COMPLETE!" + ) + return None - # DELETE: event_payouts - c.execute( - """ - DELETE - FROM event_payout - WHERE ext_ref_id = %s - AND uuid = ANY(%s) - """, - [ext_ref_id, event_payout_uuids], + for bp_pe in bpe.bp_payouts: + if bp_pe.status == PayoutStatus.PENDING: + product = pm.get_by_uuid(bp_pe.product_id) + self.retry_create_bp_payout_event_tx( + thl_ledger_manager=thl_lm, bp_pe=bp_pe, product=product ) - conn.commit() - + assert all( + bp_pe.status == PayoutStatus.COMPLETE for bp_pe in bpe.bp_payouts + ), "We created all BP payouts, but the statuses are not complete?" + self.validate_business_payout_in_ledger(ext_ref_id=ext_ref_id, thl_lm=thl_lm) + self.update_business_payout_event(pk=bpe.id, status=PayoutStatus.COMPLETE) return None def get_business_payout_events_for_products( self, - thl_ledger_manager: ThlLedgerManager, product_uuids: Collection[UUIDStr], order_by: OrderBy | None = OrderBy.ASC, ) -> list[BusinessPayoutEvent]: res = self.get_bp_bp_payout_events_for_products( - thl_ledger_manager=thl_ledger_manager, product_uuids=product_uuids, order_by=order_by, ) @@ -1101,16 +1004,6 @@ class BusinessPayoutEventManager(BrokerageProductPayoutEventManager): specific Business that was paid out and how much. It then determines how to distribute the amount to each Brokerage Product in the Business. - - :param business - :param amount - :param pm - :param thl_lm: this must have rw permissions to add transactions to - the ledger - :param created - :param transaction_id - - :return: """ assert business.balance is not None, ( "Must provide a full version of a Business in order to calculate" @@ -1224,6 +1117,11 @@ class BusinessPayoutEventManager(BrokerageProductPayoutEventManager): skip_wallet_balance_check=True, ) + self.update_business_payout_event(pk=bpe.id, status=PayoutStatus.COMPLETE) + + return bpe + + def update_business_payout_event(self, pk: int, status: PayoutStatus): with self.connection() as conn: with conn.cursor() as c: c.execute( @@ -1231,10 +1129,11 @@ class BusinessPayoutEventManager(BrokerageProductPayoutEventManager): UPDATE supplier_payout SET status = %(status)s WHERE id = %(pk)s""", - {"pk": bpe.id, "status": PayoutStatus.COMPLETE}, + {"pk": pk, "status": status}, ) - - return bpe + assert c.rowcount == 1, f"{id=} not found" + conn.commit() + return None # import duckdb diff --git a/generalresearch/models/gr/business.py b/generalresearch/models/gr/business.py index cbb4bcb..bd66fbf 100644 --- a/generalresearch/models/gr/business.py +++ b/generalresearch/models/gr/business.py @@ -443,7 +443,6 @@ class Business(BaseModel): self.prefetch_products(thl_pg_config=thl_pg_config) self.payouts = bpem.get_business_payout_events_for_products( - thl_ledger_manager=thl_lm, product_uuids=self.product_uuids, order_by=OrderBy.DESC, ) diff --git a/generalresearch/models/thl/payout.py b/generalresearch/models/thl/payout.py index 3c89e3b..2ecc232 100644 --- a/generalresearch/models/thl/payout.py +++ b/generalresearch/models/thl/payout.py @@ -18,7 +18,7 @@ from pydantic.json_schema import SkipJsonSchema from typing_extensions import Self from generalresearch.currency import USDCent -from generalresearch.models.custom_types import AwareDatetimeISO, UUIDStr +from generalresearch.models.custom_types import AwareDatetimeISO, UUIDStr, UUIDStrCoerce from generalresearch.models.thl.definitions import PayoutStatus from generalresearch.models.thl.ledger import OrderBy from generalresearch.models.thl.wallet import PayoutType @@ -42,20 +42,20 @@ class PayoutEvent(BaseModel): multiple BrokerageProductPayoutEvents. """ - uuid: UUIDStr = Field( + uuid: UUIDStrCoerce = Field( title="Payout Event Unique Identifier", default_factory=lambda: uuid4().hex, examples=["9453cd076713426cb68d05591c7145aa"], ) - debit_account_uuid: UUIDStr | None = Field( + debit_account_uuid: UUIDStrCoerce | None = Field( description="The LedgerAccount.uuid that money is being requested from. " "Thie User or Brokerage Product is retrievable through the " "LedgerAccount.reference_uuid", examples=["18298cb1583846fbb06e4747b5310693"], ) - cashout_method_uuid: UUIDStr | None = Field( + cashout_method_uuid: UUIDStrCoerce | None = Field( description="References a row in the account_cashoutmethod table. This " "is the cashout method that was used to request this " "payout. (A cashout is the same thing as a payout)", @@ -198,7 +198,7 @@ class BrokerageProductPayoutEvent(PayoutEvent): - created: When the Brokerage Product was paid out """ - product_id: UUIDStr = Field( + product_id: UUIDStrCoerce = Field( description="The Brokerage Product that was paid out", examples=["1108d053e4fa47c5b0dbdcd03a7981e7"], ) @@ -222,60 +222,6 @@ class BrokerageProductPayoutEvent(PayoutEvent): def amount_usd_str(self) -> str: return self.amount_usd.to_usd_str() - # --- ORM --- - - @classmethod - def from_payout_event( - cls, - pe: PayoutEvent, - account_product_mapping: dict[UUIDStr, UUIDStr] | None = None, - redis_config: RedisConfig | None = None, - ) -> Self: - # TODO!: prevent re-assignment, rework this... - - if account_product_mapping is None: - rc = redis_config.create_redis_client() - account_product_mapping: dict = rc.hgetall(name="pem:account_to_product") - assert isinstance(account_product_mapping, dict) - assert pe.uuid in account_product_mapping.keys() - - d = pe.model_dump() - d["product_id"] = account_product_mapping[pe.debit_account_uuid] - return cls.model_validate(d) - - @classmethod - def from_payout_events( - cls, - payout_events: Collection[PayoutEvent], - order_by=OrderBy, - account_product_mapping: dict[UUIDStr, UUIDStr] | None = None, - redis_config: RedisConfig | None = None, - ) -> list[Self]: - # TODO!: prevent re-assignment, rework this... - - if account_product_mapping is None: - rc = redis_config.create_redis_client() - account_product_mapping: dict = rc.hgetall(name="pem:account_to_product") - assert isinstance(account_product_mapping, dict) - - res = [] - for pe in payout_events: - res.append( - cls.from_payout_event( - pe=pe, account_product_mapping=account_product_mapping - ) - ) - - match order_by: - case OrderBy.ASC: - sorted_list = sorted(res, key=lambda x: x.created, reverse=False) - case OrderBy.DESC: - sorted_list = sorted(res, key=lambda x: x.created, reverse=True) - case _: - raise ValueError("Invalid order provided..") - - return sorted_list - class BusinessPayoutEvent(BaseModel): """A single payout event to a supplier Business.""" diff --git a/generalresearch/models/thl/product.py b/generalresearch/models/thl/product.py index 3f21d92..9b7d66a 100644 --- a/generalresearch/models/thl/product.py +++ b/generalresearch/models/thl/product.py @@ -1207,7 +1207,6 @@ class Product(BaseModel, validate_assignment=True): from generalresearch.models.thl.ledger import OrderBy self.payouts = bp_pem.get_bp_bp_payout_events_for_products( - thl_ledger_manager=thl_lm, product_uuids=[self.uuid], order_by=OrderBy.DESC, ) diff --git a/tests/managers/thl/test_payout.py b/tests/managers/thl/test_payout.py index f118c71..d586ba2 100644 --- a/tests/managers/thl/test_payout.py +++ b/tests/managers/thl/test_payout.py @@ -9,7 +9,8 @@ import io import pandas as pd import pytest - +from generalresearch.models.thl.product import Product +from generalresearch.models.thl.user import User from generalresearch.currency import USDCent from generalresearch.managers.thl.ledger_manager.exceptions import ( LedgerTransactionConditionFailedError, @@ -363,8 +364,6 @@ class TestBusinessPayoutEventManager: delete_ledger_db() create_main_accounts() - from generalresearch.models.thl.product import Product - p1: Product = product_factory(business=business) thl_lm.get_account_or_create_bp_wallet(product=p1) business_payout_event_manager.set_account_lookup_table(thl_lm=thl_lm) @@ -446,9 +445,6 @@ class TestBusinessPayoutEventManager: create_main_accounts() delete_df_collection(coll=ledger_collection) - from generalresearch.models.thl.product import Product - from generalresearch.models.thl.user import User - p1: Product = product_factory(business=business) u1: User = user_factory(product=p1) thl_lm.get_account_or_create_bp_wallet(product=p1) @@ -532,9 +528,6 @@ class TestBusinessPayoutEventManager: create_main_accounts() delete_df_collection(coll=ledger_collection) - from generalresearch.models.thl.product import Product - from generalresearch.models.thl.user import User - p1: Product = product_factory(business=business) u1: User = user_factory(product=p1) thl_lm.get_account_or_create_bp_wallet(product=p1) @@ -762,9 +755,6 @@ class TestBusinessPayoutEventManager: create_main_accounts() delete_df_collection(coll=ledger_collection) - from generalresearch.models.thl.product import Product - from generalresearch.models.thl.user import User - p1: Product = product_factory(business=business) u1: User = user_factory(product=p1) thl_lm.get_account_or_create_bp_wallet(product=p1) @@ -827,6 +817,114 @@ class TestBusinessPayoutEventManager: ), ] + def test_create_from_ach_or_wire( + self, + product, + mnt_filepath, + thl_lm, + client_no_amm, + thl_redis_config, + payout_event_manager, + brokerage_product_payout_event_manager, + business_payout_event_manager, + delete_ledger_db, + create_main_accounts, + delete_df_collection, + ledger_collection, + business, + user_factory, + product_factory, + session_with_tx_factory, + pop_ledger_merge, + start, + bp_payout_factory, + adj_to_fail_with_tx_factory, + thl_web_rr, + lm, + product_manager, + rm_ledger_collection, + rm_pop_ledger_merge, + caplog, + ): + """Test having a Business with three products""" + # Now let's load it up and actually test some things + delete_ledger_db() + create_main_accounts() + delete_df_collection(coll=ledger_collection) + + p1: Product = product_factory(business=business) + p2: Product = product_factory(business=business) + p3: Product = product_factory(business=business) + u1: User = user_factory(product=p1) + u2: User = user_factory(product=p2) + u3: User = user_factory(product=p3) + thl_lm.get_account_or_create_bp_wallet(product=p1) + thl_lm.get_account_or_create_bp_wallet(product=p2) + thl_lm.get_account_or_create_bp_wallet(product=p3) + + ach_id1 = uuid4().hex + ach_id2 = uuid4().hex + + # Product 1: Complete, Payout, Recon.. + s1 = session_with_tx_factory( + user=u1, + wall_req_cpi=Decimal("5.00"), + started=start + timedelta(days=1), + ) + + # Product 2: Complete x10 + for idx in range(15): + session_with_tx_factory( + user=u2, + wall_req_cpi=Decimal("7.50"), + started=start + timedelta(days=1, hours=2, minutes=1 + idx), + ) + + # Product 3: Complete x5 + for idx in range(10): + session_with_tx_factory( + user=u3, + wall_req_cpi=Decimal("7.50"), + started=start + timedelta(days=1, hours=3, minutes=1 + idx), + ) + + ledger_collection.initial_load(client=None, sync=True) + pop_ledger_merge.build(client=client_no_amm, ledger_coll=ledger_collection) + business.prebuild_balance( + thl_pg_config=thl_web_rr, + lm=lm, + ds=mnt_filepath, + client=client_no_amm, + pop_ledger=pop_ledger_merge, + ) + + bb = business.balance + assert bb.payout == 25 * 712 + 475 # $7.50 * .95% = $7.125 = $7.12 + assert bb.net == (25 * 7.12 + 4.75) * 100 + + bp1 = business_payout_event_manager.create_from_ach_or_wire( + business=business, + amount=USDCent(bb.available_balance), + pm=product_manager, + thl_lm=thl_lm, + created=start + timedelta(days=1, hours=5), + transaction_id=ach_id1, + ) + print(f"{bp1=}") + assert isinstance(bp1, BusinessPayoutEvent) + assert len(bp1.bp_payouts) == 3 + + with caplog.at_level(logging.WARNING): + business_payout_event_manager.resume_failed_business_payout( + ext_ref_id=ach_id1, thl_lm=thl_lm, pm=product_manager + ) + assert "Nothing to do!" in caplog.text + + bpe = business_payout_event_manager.get_by_ext_ref_id(ext_ref_id=ach_id1) + + assert 1 == 0 + return None + def test_ach_payment( self, product, @@ -866,9 +964,6 @@ class TestBusinessPayoutEventManager: create_main_accounts() delete_df_collection(coll=ledger_collection) - from generalresearch.models.thl.product import Product - from generalresearch.models.thl.user import User - p1: Product = product_factory(business=business) p2: Product = product_factory(business=business) p3: Product = product_factory(business=business) @@ -1072,9 +1167,6 @@ class TestBusinessPayoutEventManager: create_main_accounts() delete_df_collection(coll=ledger_collection) - from generalresearch.models.thl.product import Product - from generalresearch.models.thl.user import User - p1: Product = product_factory(business=business) p2: Product = product_factory(business=business) p3: Product = product_factory(business=business) @@ -1204,9 +1296,6 @@ class TestBusinessPayoutEventManager: create_main_accounts() delete_df_collection(coll=ledger_collection) - from generalresearch.models.thl.product import Product - from generalresearch.models.thl.user import User - p1: Product = product_factory(business=business) p2: Product = product_factory(business=business) p3: Product = product_factory(business=business) |
