diff options
| -rw-r--r-- | generalresearch/gr_django/__init__.py | 0 | ||||
| -rw-r--r-- | generalresearch/gr_django/app/__init__.py | 0 | ||||
| -rw-r--r-- | generalresearch/gr_django/app/manage.py | 13 | ||||
| -rw-r--r-- | generalresearch/gr_django/app/settings.py | 24 | ||||
| -rw-r--r-- | generalresearch/gr_django/apps.py | 7 | ||||
| -rw-r--r-- | generalresearch/gr_django/migrations/0001_initial.py | 98 | ||||
| -rw-r--r-- | generalresearch/gr_django/migrations/0002_business_balance_business_balance_net_7d_and_more.py | 38 | ||||
| -rw-r--r-- | generalresearch/gr_django/migrations/__init__.py | 0 | ||||
| -rw-r--r-- | generalresearch/gr_django/models.py | 146 | ||||
| -rw-r--r-- | generalresearch/gr_django/readme.MD | 5 | ||||
| -rw-r--r-- | generalresearch/managers/gr/business.py | 43 | ||||
| -rw-r--r-- | generalresearch/managers/thl/payout.py | 16 | ||||
| -rw-r--r-- | generalresearch/models/gr/business.py | 129 | ||||
| -rw-r--r-- | generalresearch/models/thl/finance.py | 194 | ||||
| -rw-r--r-- | tests/models/thl/test_product.py | 2 |
15 files changed, 650 insertions, 65 deletions
diff --git a/generalresearch/gr_django/__init__.py b/generalresearch/gr_django/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/generalresearch/gr_django/__init__.py diff --git a/generalresearch/gr_django/app/__init__.py b/generalresearch/gr_django/app/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/generalresearch/gr_django/app/__init__.py diff --git a/generalresearch/gr_django/app/manage.py b/generalresearch/gr_django/app/manage.py new file mode 100644 index 0000000..77700ed --- /dev/null +++ b/generalresearch/gr_django/app/manage.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault( + "DJANGO_SETTINGS_MODULE", + "generalresearch.gr_django.app.settings", + ) + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv)
\ No newline at end of file diff --git a/generalresearch/gr_django/app/settings.py b/generalresearch/gr_django/app/settings.py new file mode 100644 index 0000000..857870e --- /dev/null +++ b/generalresearch/gr_django/app/settings.py @@ -0,0 +1,24 @@ +import os + +INSTALLED_APPS = [ + "django.contrib.postgres", + "django.contrib.contenttypes", + "generalresearch.gr_django.apps.GRSchemaConfig", +] + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ.get("GR_DB_NAME", "gr"), + "USER": os.environ.get("GR_DB_USER", "postgres"), + "PASSWORD": os.environ.get("GR_DB_PASSWORD", "password"), + "HOST": os.environ.get("GR_DB_HOST", "127.0.0.1"), + "PORT": os.environ.get("GR_DB_PORT", "5432"), + } +} + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_TZ = True
\ No newline at end of file diff --git a/generalresearch/gr_django/apps.py b/generalresearch/gr_django/apps.py new file mode 100644 index 0000000..f6a2b62 --- /dev/null +++ b/generalresearch/gr_django/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class GRSchemaConfig(AppConfig): + name = "generalresearch.gr_django" + label = "common" # preserves the existing gr-carer migration identity + default_auto_field = "django.db.models.BigAutoField"
\ No newline at end of file diff --git a/generalresearch/gr_django/migrations/0001_initial.py b/generalresearch/gr_django/migrations/0001_initial.py new file mode 100644 index 0000000..712592b --- /dev/null +++ b/generalresearch/gr_django/migrations/0001_initial.py @@ -0,0 +1,98 @@ +# Generated by Django 5.2.6 on 2025-09-26 19:41 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Business', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)), + ('kind', models.CharField(choices=[('i', 'Individual'), ('c', 'Company')], default='i', max_length=1)), + ('name', models.CharField(blank=True, max_length=255)), + ('tax_number', models.CharField(blank=True, max_length=20, null=True)), + ], + ), + migrations.CreateModel( + name='Membership', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)), + ('user_id', models.BigIntegerField(db_index=True, null=True)), + ('team_id', models.BigIntegerField(db_index=True, null=True)), + ('privilege', models.PositiveSmallIntegerField(choices=[(0, 'Admin'), (1, 'Maintain'), (2, 'Read')], default=1)), + ('owner', models.BooleanField(default=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('sub', models.CharField(max_length=200)), + ('is_superuser', models.BooleanField(default=False)), + ('date_joined', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'gr_user', + }, + ), + migrations.CreateModel( + name='UserToken', + fields=[ + ('key', models.CharField(max_length=255, primary_key=True, serialize=False)), + ('user_id', models.BigIntegerField(db_index=True, null=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'gr_token', + }, + ), + migrations.CreateModel( + name='BankAccount', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)), + ('transfer_method', models.PositiveSmallIntegerField(choices=[(0, 'ACH'), (1, 'Wire')], default=0)), + ('account_number', models.CharField(blank=True, max_length=50, null=True)), + ('routing_number', models.CharField(blank=True, max_length=50, null=True)), + ('iban', models.CharField(blank=True, max_length=50, null=True)), + ('swift', models.CharField(blank=True, max_length=50, null=True)), + ('business', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='common.business')), + ], + ), + migrations.CreateModel( + name='BusinessAddress', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)), + ('line_1', models.CharField(blank=True, max_length=255, null=True)), + ('line_2', models.CharField(blank=True, max_length=255, null=True)), + ('city', models.CharField(blank=True, max_length=255, null=True)), + ('country', models.CharField(blank=True, max_length=2, null=True)), + ('state', models.CharField(blank=True, max_length=255, null=True)), + ('postal_code', models.CharField(blank=True, max_length=12, null=True)), + ('phone_number', models.CharField(max_length=20, null=True)), + ('business', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='common.business')), + ], + ), + migrations.CreateModel( + name='Team', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)), + ('name', models.CharField(blank=True, max_length=255, null=True)), + ('businesses', models.ManyToManyField(to='common.business')), + ], + ), + ] diff --git a/generalresearch/gr_django/migrations/0002_business_balance_business_balance_net_7d_and_more.py b/generalresearch/gr_django/migrations/0002_business_balance_business_balance_net_7d_and_more.py new file mode 100644 index 0000000..d355368 --- /dev/null +++ b/generalresearch/gr_django/migrations/0002_business_balance_business_balance_net_7d_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 6.1.1 on 2026-10-09 16:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='business', + name='balance', + field=models.JSONField(default=None, null=True), + ), + migrations.AddField( + model_name='business', + name='balance_net_7d', + field=models.IntegerField(default=None, null=True), + ), + migrations.AddField( + model_name='business', + name='task_completes_7d', + field=models.IntegerField(default=None, null=True), + ), + migrations.AddField( + model_name='business', + name='user_wallet_balance', + field=models.JSONField(default=None, null=True), + ), + migrations.AddField( + model_name='business', + name='users_active_7d', + field=models.IntegerField(default=None, null=True), + ), + ] diff --git a/generalresearch/gr_django/migrations/__init__.py b/generalresearch/gr_django/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/generalresearch/gr_django/migrations/__init__.py diff --git a/generalresearch/gr_django/models.py b/generalresearch/gr_django/models.py new file mode 100644 index 0000000..01bf749 --- /dev/null +++ b/generalresearch/gr_django/models.py @@ -0,0 +1,146 @@ +import uuid + +from django.db import models + + +class User(models.Model): + id = models.BigAutoField(primary_key=True) + sub = models.CharField(max_length=200, blank=False, null=False) + + is_superuser = models.BooleanField(default=False) + date_joined = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "gr_user" + + +class UserToken(models.Model): + key = models.CharField(max_length=255, primary_key=True) + user_id = models.BigIntegerField(null=True, db_index=True) + created = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "gr_token" + + +class Membership(models.Model): + """ + A User on generalresearch.com can be a member of many teams, that relationship can have different + permission to manage a team or accounts and business entities owned by that team + """ + + id = models.BigAutoField(primary_key=True) + uuid = models.UUIDField(default=uuid.uuid4, unique=True) + + user_id = models.BigIntegerField(null=True, db_index=True) + team_id = models.BigIntegerField(null=True, db_index=True) + + PRIVILEGES = ( + (0, "Admin"), + (1, "Maintain"), + (2, "Read"), + ) + privilege = models.PositiveSmallIntegerField(default=1, choices=PRIVILEGES) + owner = models.BooleanField(default=False) + + created = models.DateTimeField(auto_now_add=True, null=False) + + +class Team(models.Model): + """ + This is a collection of users / accounts that work as a team and share + access to the same BPID However, an Organization can have many Bank + Accounts as each BPID doesn't need to deposit funds into the same Bank + account or Business + """ + + id = models.BigAutoField(primary_key=True) + uuid = models.UUIDField(default=uuid.uuid4, unique=True) + + name = models.CharField(max_length=255, null=True, blank=True) + + businesses = models.ManyToManyField(to="common.Business") + + +class Business(models.Model): + """ + A Team can manage multiple Businesses + A Business can have multiple Brokerage Products + + # The IRS responsible entity that our accounts care about for issuing 1099s. + # We have this seperated from the Team as some Teams have different + # bank accounts + corporate entities for the activity of an app + """ + + id = models.BigAutoField(primary_key=True) + uuid = models.UUIDField(default=uuid.uuid4, unique=True) + + BUSINESS_KINDS = (("i", "Individual"), ("c", "Company")) + kind = models.CharField(max_length=1, choices=BUSINESS_KINDS, default="i") + name = models.CharField(max_length=255, blank=True, null=False) + + tax_number = models.CharField(max_length=20, blank=True, null=True) + + # BusinessBalances model + balance = models.JSONField(default=None, null=True) + # BusinessUserWalletBalances model + user_wallet_balance = models.JSONField(default=None, null=True) + + users_active_7d = models.IntegerField(default=None, null=True) + task_completes_7d = models.IntegerField(default=None, null=True) + # Net Earnings over the last 7 days (in USD Cents, this can be positive or negative) + balance_net_7d = models.IntegerField(default=None, null=True) + + +class BusinessAddress(models.Model): + id = models.BigAutoField(primary_key=True) + uuid = models.UUIDField(default=uuid.uuid4, unique=True) + + business = models.ForeignKey( + "common.Business", + null=True, + on_delete=models.SET_NULL, + ) + + # Location Details + line_1 = models.CharField(max_length=255, blank=True, null=True) + line_2 = models.CharField(max_length=255, blank=True, null=True) + city = models.CharField(max_length=255, blank=True, null=True) + country = models.CharField( + max_length=2, blank=True, null=True + ) # Validate this with pycountry + state = models.CharField(max_length=255, blank=True, null=True) + postal_code = models.CharField(max_length=12, blank=True, null=True) + phone_number = models.CharField(max_length=20, null=True) + + +class BankAccount(models.Model): + """ + A Business can have a single active Bank Account at the time. + This is used to deposit funds earned from the Business's Brokerage Products. + """ + + id = models.BigAutoField(primary_key=True) + uuid = models.UUIDField(default=uuid.uuid4, unique=True) + + business = models.ForeignKey( + "common.Business", + null=True, + on_delete=models.SET_NULL, + ) + + TRANSFER_METHOD = ( + (0, "ACH"), + (1, "Wire"), + ) + transfer_method = models.PositiveSmallIntegerField( + choices=TRANSFER_METHOD, default=0 + ) + + # ACH requirements + account_number = models.CharField(max_length=50, blank=True, null=True) + routing_number = models.CharField(max_length=50, blank=True, null=True) + + # Wire requirements + iban = models.CharField(max_length=50, blank=True, null=True) + swift = models.CharField(max_length=50, blank=True, null=True) diff --git a/generalresearch/gr_django/readme.MD b/generalresearch/gr_django/readme.MD new file mode 100644 index 0000000..1a6e682 --- /dev/null +++ b/generalresearch/gr_django/readme.MD @@ -0,0 +1,5 @@ +Run migrations with: +```shell +python -m generalresearch.gr_django.app.manage makemigrations common +python -m generalresearch.gr_django.app.manage migrate +```
\ No newline at end of file diff --git a/generalresearch/managers/gr/business.py b/generalresearch/managers/gr/business.py index bb7944d..734ba55 100644 --- a/generalresearch/managers/gr/business.py +++ b/generalresearch/managers/gr/business.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from typing import TYPE_CHECKING from uuid import UUID, uuid4 @@ -155,6 +156,21 @@ class BusinessManager(PostgresManagerWithRedis): referenced by the gr-api services """ + CACHED_FIELDS: frozenset[str] = frozenset( + { + "balance", + "user_wallet_balance", + "users_active_7d", + "task_completes_7d", + "balance_net_7d", + } + ) + CACHED_FIELDS_JSON: frozenset[str] = frozenset( + { + "balance", + "user_wallet_balance", + } + ) def get_or_create( self, @@ -393,3 +409,30 @@ class BusinessManager(PostgresManagerWithRedis): return None return Business.model_validate(res[0]) + + def update_cached_fields(self, business: Business) -> None: + """ + Use this to update any of the cached keys on a business + or any of the calculated fields such as users_active_7d. + All supported fields are updated at once + """ + data = business.model_dump(mode="json", include=set(self.CACHED_FIELDS)) + for k, v in data.items(): + if k in self.CACHED_FIELDS_JSON and v is not None: + data[k] = json.dumps(v) + + assert business.id is not None, "Business must be persisted before updating" + assignments = sql.SQL(", ").join( + sql.SQL("{} = %s").format(sql.Identifier(field)) for field in data + ) + query = sql.SQL(""" + UPDATE common_business + SET {assignments} + WHERE id = %s + """).format(assignments=assignments) + params = [*data.values(), business.id] + + with self.pg_config.make_connection() as conn: + with conn.cursor() as cursor: + cursor.execute(query, params) + conn.commit() diff --git a/generalresearch/managers/thl/payout.py b/generalresearch/managers/thl/payout.py index af4f712..4a3aad7 100644 --- a/generalresearch/managers/thl/payout.py +++ b/generalresearch/managers/thl/payout.py @@ -9,7 +9,7 @@ import numpy as np import pandas as pd import psycopg from psycopg import sql -from pydantic import AwareDatetime, NonNegativeInt, PositiveInt +from pydantic import AwareDatetime, NonNegativeInt from generalresearch.currency import USDCent from generalresearch.decorators import LOG @@ -20,7 +20,7 @@ from generalresearch.managers.thl.ledger_manager.exceptions import ( LedgerTransactionConditionFailedError, LedgerTransactionReleaseLockError, ) -from generalresearch.models.custom_types import AwareDatetimeISO, UUIDStr +from generalresearch.models.custom_types import UUIDStr from generalresearch.models.thl.definitions import PayoutStatus from generalresearch.models.thl.ledger import ( Direction, @@ -101,7 +101,6 @@ class PayoutEventManager(PostgresManagerWithRedis): conn.commit() - class BrokerageProductPayoutEventManager(PayoutEventManager): # This is what makes a PayoutEvent a Brokerage Product Payout CASHOUT_METHOD_UUID = "602113e330cf43ae85c07d94b5100291" @@ -767,9 +766,18 @@ class BusinessPayoutEventManager(PostgresManagerWithRedis): # a simple DF. We're using the available balance because we need it # to always be positive. We never want to get into a negative # situation again, so it's best to be extra conservative. + if business.products is None: + business.prefetch_products(product_manager=pm) + assert business.products is not None + + product_balances = [product.balance for product in business.products] + if any(balance is None for balance in product_balances): + raise ValueError("Every Business Product must have a balance") + balances = { pb.product_id: pb.available_balance - for pb in business.balance.product_balances + for pb in product_balances + if pb is not None } df = pd.DataFrame.from_dict(balances, orient="index").reset_index() df.columns = ["product_id", "available_balance"] diff --git a/generalresearch/models/gr/business.py b/generalresearch/models/gr/business.py index 5ed85f2..d5a4c0a 100644 --- a/generalresearch/models/gr/business.py +++ b/generalresearch/models/gr/business.py @@ -30,10 +30,13 @@ from generalresearch.models.custom_types import ( ) from generalresearch.models.gr.definitions import BusinessType, TransferMethod from generalresearch.models.gr.team import Team -from generalresearch.models.thl.finance import BusinessBalances, POPFinancial +from generalresearch.models.thl.finance import ( + BusinessBalances, + BusinessUserWalletBalances, + POPFinancial, +) from generalresearch.models.thl.ledger import LedgerAccount, OrderBy from generalresearch.models.thl.payout import BusinessPayoutEvent -from generalresearch.utils.aggregation import group_by_year if TYPE_CHECKING: from generalresearch.incite.mergers.pop_ledger import PopLedgerMerge @@ -203,6 +206,7 @@ class Business(BaseModel): # Initialization is deferred until unless it's called # (see .prebuild_***()) balance: BusinessBalances | None = Field(default=None, title="Business Balance") + user_wallet_balance: BusinessUserWalletBalances | None = Field(default=None) payouts_total_str: str | None = Field(default=None) payouts_total: USDCent | None = Field(default=None) @@ -219,6 +223,17 @@ class Business(BaseModel): pop_financial: list[POPFinancial] | None = Field(default=None) bp_accounts: list[LedgerAccount] | None = Field(default=None) + users_active_7d: int | None = Field( + default=None, description="Count of active users in the past 7 days" + ) + task_completes_7d: int | None = Field( + default=None, description="Count of completes in the past 7 days" + ) + balance_net_7d: int | None = Field( + default=None, + description="Net Earnings over the last 7 days (in USD Cents, this can be positive or negative)", + ) + def __str__(self) -> str: return ( f"Name: {self.name} ({self.uuid})\n" @@ -238,9 +253,10 @@ class Business(BaseModel): # --- Prefetch --- def prefetch_addresses(self, pg_config: PostgresConfig) -> None: - with pg_config.make_connection() as conn, conn.cursor( - row_factory=dict_row - ) as c: + with ( + pg_config.make_connection() as conn, + conn.cursor(row_factory=dict_row) as c, + ): c.execute( query=""" SELECT * @@ -258,9 +274,10 @@ class Business(BaseModel): self.addresses = [BusinessAddress.model_validate(i) for i in res] def prefetch_teams(self, pg_config: PostgresConfig) -> None: - with pg_config.make_connection() as conn, conn.cursor( - row_factory=dict_row - ) as c: + with ( + pg_config.make_connection() as conn, + conn.cursor(row_factory=dict_row) as c, + ): c: Cursor c.execute( @@ -340,6 +357,45 @@ class Business(BaseModel): # --- Prebuild --- + def prebuild_user_wallet_balance( + self, + client: DaskClient, + pop_ledger: PopLedgerMerge | None = None, + ): + from generalresearch.incite.schemas.mergers.pop_ledger import ( + numerical_col_names, + ) + + assert self.products is not None, "prefetch products" + product_uuids = self.product_uuids + assert product_uuids is not None, "prefetch products" + if not any(p.user_wallet_enabled for p in self.products): + return + + ddf = pop_ledger.ddf( + force_rr_latest=False, + include_partial=True, + columns=numerical_col_names + + ["time_idx", "account_id", "product_id", "product_user_id"], + filters=[("product_id", "in", list(product_uuids))], + ) + df: pd.DataFrame = client.compute(collections=ddf, sync=True) + for product in self.products: + if product.user_wallet_enabled and product.user_wallet_balance is None: + product.prebuild_user_wallet_balances(df) + product_user_wallet_balances = [ + p.user_wallet_balance + for p in self.products + if p.user_wallet_balance is not None + ] + if product_user_wallet_balances: + self.user_wallet_balance = ( + BusinessUserWalletBalances.from_product_user_wallet_balances( + business_id=self.uuid, + product_user_wallet_balances=product_user_wallet_balances, + ) + ) + def prebuild_balance( self, product_manager: ProductManager, @@ -434,6 +490,26 @@ class Business(BaseModel): return + def prebuild_metrics(self, product_manager: ProductManager): + """ + Simply sum the products' metrics + """ + product_ids = self.product_uuids + query = """ + SELECT + SUM(users_active_7d) AS users_active_7d, + SUM(task_completes_7d) AS task_completes_7d, + SUM(balance_net_7d) AS balance_net_7d + FROM userprofile_brokerageproduct + WHERE id = ANY(%(product_ids)s) + """ + with product_manager.pg_config.make_connection() as conn, conn.cursor() as c: + c.execute(query, {"product_ids": product_ids}) + res = c.fetchone() + self.users_active_7d = res["users_active_7d"] + self.task_completes_7d = res["task_completes_7d"] + self.balance_net_7d = res["balance_net_7d"] + def prebuild_payouts( self, bpem: BusinessPayoutEventManager, @@ -626,7 +702,6 @@ class Business(BaseModel): pg_config: PostgresConfig, product_manager: ProductManager, business_bank_account_manager: BusinessBankAccountManager, - thl_web_rr: PostgresConfig, redis_config: RedisConfig, client: DaskClient, ds: GRLDatasets, @@ -641,6 +716,10 @@ class Business(BaseModel): LOG.debug(f"Business.set_cache({self.uuid=})") # ex_secs = 60 * 60 * 24 * 3 # 3 days + if pop_ledger is None: + from generalresearch.incite.defaults import pop_ledger as plm + + pop_ledger = plm(ds=ds) self.prefetch_addresses(pg_config=pg_config) self.prefetch_teams(pg_config=pg_config) @@ -657,26 +736,32 @@ class Business(BaseModel): client=client, pop_ledger=pop_ledger, ) - self.prebuild_payouts(bpem=bpem) - self.prebuild_pop_financial( - product_manager=product_manager, - thl_lm=thl_lm, - ds=ds, + self.prebuild_user_wallet_balance( client=client, pop_ledger=pop_ledger, ) + self.prebuild_payouts(bpem=bpem) + self.prebuild_metrics(product_manager=product_manager) + # self.prebuild_pop_financial( + # product_manager=product_manager, + # thl_lm=thl_lm, + # ds=ds, + # client=client, + # pop_ledger=pop_ledger, + # ) + rc = redis_config.create_redis_client() mapping = self.model_dump(mode="json") - # For POP Financial data, we want to also break that out by year - res = { - f"pop_financial:{key}": value - for key, value in group_by_year( - records=mapping["pop_financial"], datetime_field="time" - ).items() - } - mapping = mapping | res + # # For POP Financial data, we want to also break that out by year + # res = { + # f"pop_financial:{key}": value + # for key, value in group_by_year( + # records=mapping["pop_financial"], datetime_field="time" + # ).items() + # } + # mapping = mapping | res for key in mapping: mapping[key] = json.dumps(mapping[key]) diff --git a/generalresearch/models/thl/finance.py b/generalresearch/models/thl/finance.py index f5e8745..49672f1 100644 --- a/generalresearch/models/thl/finance.py +++ b/generalresearch/models/thl/finance.py @@ -442,27 +442,10 @@ def _empty_user_wallet_balance_histogram() -> list[UserWalletBalanceHistogramBuc ] -class ProductUserWalletBalances(BaseModel): - """Aggregate user-wallet activity for one Brokerage Product. - - All monetary values are integer USD cents. - - These totals should be computed directly by the data layer rather than by - materializing every ``UserWalletBalances`` instance. Products may have many - thousands of user wallets. - - Positive and negative wallet balances are aggregated separately because - a negative user balance MUST not reduce the BP's outstanding liability to - users with positive balances. - - Pending payouts are reported separately because a payout request removes funds - from the user's wallet before disbursement. +class UserWalletBalanceSummary(BaseModel): + """ + Abstract class for aggregating UserWalletBalance objects -> {ProductUserWalletBalances, BusinessUserWalletBalances} """ - - product_id: UUIDStr = Field( - description="Brokerage Product represented by this aggregation.", - examples=[uuid4().hex], - ) debit: NonNegativeInt = Field( default=0, @@ -585,6 +568,46 @@ class ProductUserWalletBalances(BaseModel): ) return self + @computed_field( + title="Wallet Balance", + description=( + "Net balance across all user wallets (credits minus debits). This is a " + "net position, not the BP's outstanding liability when any individual " + "wallet has a negative balance." + ), + examples=[5_341], + ) + @property + def balance(self) -> int: + return self.credit + (self.debit * -1) + + def to_prometheus(self) -> tuple[bytes, str]: + """Render this Product as a Prometheus response body and content type.""" + return self.many_to_prometheus([self]) + + +class ProductUserWalletBalances(UserWalletBalanceSummary): + """Aggregate user-wallet activity for one Brokerage Product. + + All monetary values are integer USD cents. + + These totals should be computed directly by the data layer rather than by + materializing every ``UserWalletBalances`` instance. Products may have many + thousands of user wallets. + + Positive and negative wallet balances are aggregated separately because + a negative user balance MUST not reduce the BP's outstanding liability to + users with positive balances. + + Pending payouts are reported separately because a payout request removes funds + from the user's wallet before disbursement. + """ + + product_id: UUIDStr = Field( + description="Brokerage Product represented by this aggregation.", + examples=[uuid4().hex], + ) + @classmethod def from_pop_ledger( cls, @@ -663,23 +686,6 @@ class ProductUserWalletBalances(BaseModel): newest_event=wallet_rows["time_idx"].max(), ) - @computed_field( - title="Wallet Balance", - description=( - "Net balance across all user wallets (credits minus debits). This is a " - "net position, not the BP's outstanding liability when any individual " - "wallet has a negative balance." - ), - examples=[5_341], - ) - @property - def balance(self) -> int: - return self.credit + (self.debit * -1) - - def to_prometheus(self) -> tuple[bytes, str]: - """Render this Product as a Prometheus response body and content type.""" - return self.many_to_prometheus([self]) - @classmethod def many_to_prometheus( cls, @@ -1376,7 +1382,7 @@ class ProductBalances(BaseModel): ).replace("$-", "-$") -class BusinessBalances(BaseModel): +class BusinessBalancesCalculator(BaseModel): product_balances: list[ProductBalances] = Field(default_factory=list) # --- Validators --- @@ -1703,4 +1709,114 @@ class BusinessBalances(BaseModel): product_balances, key=lambda pb: product_uuid_order[pb.product_id] ) - return BusinessBalances.model_validate({"product_balances": product_balances}) + return BusinessBalances.from_product_balances(product_balances) + + +class BusinessUserWalletBalances(UserWalletBalanceSummary): + business_id: UUIDStr = Field( + description="Business represented by this aggregation.", + examples=[uuid4().hex], + ) + + @classmethod + def from_product_user_wallet_balances( + cls, + business_id: str, + product_user_wallet_balances: list[ProductUserWalletBalances], + ) -> BusinessUserWalletBalances: + sum_fields = [ + k + for k, v in UserWalletBalanceSummary.model_fields.items() + if v.annotation in {int, float} + ] + d = {} + for key in sum_fields: + d[key] = sum(getattr(pb, key) for pb in product_user_wallet_balances) + d["oldest_event"] = min( + [ + pb.oldest_event + for pb in product_user_wallet_balances + if pb.oldest_event is not None + ], + default=None, + ) + d["newest_event"] = max( + [ + pb.newest_event + for pb in product_user_wallet_balances + if pb.newest_event is not None + ], + default=None, + ) + d["wallet_balance_buckets"] = [ + UserWalletBalanceHistogramBucket( + upper_bound=upper_bound, + cumulative_count=sum( + product.wallet_balance_buckets[index].cumulative_count + for product in product_user_wallet_balances + ), + ) + for index, upper_bound in enumerate(USER_WALLET_BALANCE_BUCKETS) + ] + return cls.model_validate(d | {"business_id": business_id}) + + +class BusinessBalances(BaseModel): + """Persistable business-balance snapshot used by the API.""" + + model_config = ConfigDict(extra="ignore") + + product_count: NonNegativeInt + payout: int + payout_usd_str: str + adjustment: int + adjustment_usd_str: str + expense: int + expense_usd_str: str + net: int + net_usd_str: str + payment: int + payment_usd_str: str + balance: int + balance_usd_str: str + retainer: NonNegativeInt + retainer_usd_str: str + available_balance: NonNegativeInt + available_balance_usd_str: str + adjustment_percent: float + recoup: USDCent + recoup_usd_str: str + + @classmethod + def from_product_balances( + cls, product_balances: list[ProductBalances] + ) -> BusinessBalances: + calculator = BusinessBalancesCalculator(product_balances=product_balances) + values = calculator.model_dump(exclude={"product_balances"}) + values["product_count"] = len(product_balances) + return cls.model_validate(values) + + @staticmethod + def from_pandas( + input_data: pd.DataFrame, + accounts: list[LedgerAccount], + product_manager: ProductManager, + ) -> BusinessBalances: + return BusinessBalancesCalculator.from_pandas( + input_data=input_data, + accounts=accounts, + product_manager=product_manager, + ) + + def __str__(self) -> str: + return ( + f"Products: {self.product_count}\n" + f"Total Payout: ${self.payout / 100:,.2f}\n" + f"Total Adjustment: ${self.adjustment / 100:,.2f}\n" + f"Total Expense: ${self.expense / 100:,.2f}\n" + f"–––\n" + f"Net: ${self.net / 100:,.2f}\n" + f"Balance: ${self.balance / 100:,.2f}\n" + f"Smart Retainer: ${self.retainer / 100:,.2f}\n" + f"Available Balance: ${self.available_balance / 100:,.2f}" + ).replace("$-", "-$") diff --git a/tests/models/thl/test_product.py b/tests/models/thl/test_product.py index f8f3da3..d5e51f0 100644 --- a/tests/models/thl/test_product.py +++ b/tests/models/thl/test_product.py @@ -952,6 +952,8 @@ class TestProductFinancials: p1.prebuild_balance(thl_lm=thl_ledger_manager, pop_ledger_df=df) assert p1.balance.payout == 95 assert p1.balance.commission == 5 + # This will equal 0 b/c the wall events are in 2018 + assert p1.balance_net_7d == 0 p2.prebuild_balance(thl_lm=thl_ledger_manager, pop_ledger_df=df) assert p2.balance.payout == 57 |
