aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--generalresearch/gr_django/__init__.py0
-rw-r--r--generalresearch/gr_django/app/__init__.py0
-rw-r--r--generalresearch/gr_django/app/manage.py13
-rw-r--r--generalresearch/gr_django/app/settings.py24
-rw-r--r--generalresearch/gr_django/apps.py7
-rw-r--r--generalresearch/gr_django/migrations/0001_initial.py98
-rw-r--r--generalresearch/gr_django/migrations/0002_business_balance_business_balance_net_7d_and_more.py38
-rw-r--r--generalresearch/gr_django/migrations/__init__.py0
-rw-r--r--generalresearch/gr_django/models.py146
-rw-r--r--generalresearch/gr_django/readme.MD5
-rw-r--r--generalresearch/managers/gr/business.py43
-rw-r--r--generalresearch/managers/thl/payout.py16
-rw-r--r--generalresearch/models/gr/business.py129
-rw-r--r--generalresearch/models/thl/finance.py194
-rw-r--r--tests/models/thl/test_product.py2
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