diff options
| author | stuppie | 2026-09-17 13:17:47 -0600 |
|---|---|---|
| committer | stuppie | 2026-09-17 13:17:47 -0600 |
| commit | 5a5474d3f3dda3030ae888c9716efdc5dc4b8931 (patch) | |
| tree | c872c28f69b2bfb06a99dff975a16a3b23f0581d | |
| parent | 0144d37b3e9c38e5acecc1f9f1750b56b3de9749 (diff) | |
| download | generalresearch-5a5474d3f3dda3030ae888c9716efdc5dc4b8931.tar.gz generalresearch-5a5474d3f3dda3030ae888c9716efdc5dc4b8931.zip | |
rip out maxmind ipinfo geoip managers. update with grip client-based calls. plugging in user_request_redeem and all the associated checks. wip...
| -rw-r--r-- | generalresearch/managers/thl/ipinfo.py | 748 | ||||
| -rw-r--r-- | generalresearch/managers/thl/payout.py | 223 | ||||
| -rw-r--r-- | generalresearch/managers/thl/userhealth.py | 158 | ||||
| -rw-r--r-- | generalresearch/managers/thl/wallet/__init__.py | 4 | ||||
| -rw-r--r-- | generalresearch/managers/thl/wallet/cashout_tasks.py | 3 | ||||
| -rw-r--r-- | generalresearch/managers/thl/wallet/tango.py | 41 | ||||
| -rw-r--r-- | generalresearch/managers/thl/wallet/user_payout.py | 392 | ||||
| -rw-r--r-- | generalresearch/models/thl/ipinfo.py | 133 | ||||
| -rw-r--r-- | generalresearch/models/thl/maxmind/__init__.py | 0 | ||||
| -rw-r--r-- | generalresearch/models/thl/maxmind/definitions.py | 22 | ||||
| -rw-r--r-- | generalresearch/models/thl/user_iphistory.py | 85 | ||||
| -rw-r--r-- | generalresearch/models/thl/wallet/cashout_method.py | 18 |
12 files changed, 560 insertions, 1267 deletions
diff --git a/generalresearch/managers/thl/ipinfo.py b/generalresearch/managers/thl/ipinfo.py index 93914c3..575738b 100644 --- a/generalresearch/managers/thl/ipinfo.py +++ b/generalresearch/managers/thl/ipinfo.py @@ -1,736 +1,38 @@ -from __future__ import annotations - -import ipaddress from collections.abc import Collection -from decimal import Decimal -from typing import TYPE_CHECKING - -import faker -from grip_client.enums import AccessType -from more_itertools import chunked -from psycopg import Cursor -from pydantic import PositiveInt - -from generalresearch.managers.base import ( - PostgresManager, - PostgresManagerWithRedis, -) -from generalresearch.models.thl.ipinfo import ( - GeoIPInformation, - IPGeoname, - IPInformation, - normalize_ip, -) - -if TYPE_CHECKING: - from generalresearch.models.custom_types import ( - CountryISOLike, - IPvAnyAddressStr, - ) - from generalresearch.pg_helper import PostgresConfig - -fake = faker.Faker() - - -class IPGeonameManager(PostgresManager): - - def create_basic( - self, - geoname_id: PositiveInt, - is_in_european_union: bool, - country_iso: CountryISOLike, - country_name: str, - continent_code: str, - continent_name: str, - ) -> IPGeoname: - instance = IPGeoname.model_validate( - { - "geoname_id": geoname_id, - "country_iso": country_iso, - "is_in_european_union": is_in_european_union, - "country_name": country_name, - "continent_code": continent_code, - "continent_name": continent_name, - } - ) - self.pg_config.execute_write( - query=""" - INSERT INTO thl_geoname ( - geoname_id, country_iso, is_in_european_union, country_name, - continent_code, continent_name, updated - ) - VALUES ( - %(geoname_id)s, %(country_iso)s, %(is_in_european_union)s, - %(country_name)s, %(continent_code)s, %(continent_name)s, - %(updated)s - ) - ON CONFLICT (geoname_id) DO NOTHING; - """, - params=instance.model_dump(mode="json"), - ) - return instance - - def create_or_update(self, ipgeo: IPGeoname): - keys = list(ipgeo.model_fields.keys()) - data = ipgeo.model_dump_mysql() - - keys_str = ", ".join(keys) - values_str = ", ".join([f"%({k})s" for k in keys]) - update_cols = set(keys) - {"geoname_id"} - update_str = ", ".join([f"{k} = EXCLUDED.{k}" for k in update_cols]) - - query = f""" - INSERT INTO thl_geoname ({keys_str}) - VALUES ({values_str}) - ON CONFLICT (geoname_id) - DO UPDATE SET {update_str} - """ - self.pg_config.execute_write(query=query, params=data) - - def create( - self, - geoname_id: PositiveInt, - continent_code: str, - continent_name: str, - country_iso: str | None, - country_name: str | None = None, - subdivision_1_iso: str | None = None, - subdivision_1_name: str | None = None, - subdivision_2_iso: str | None = None, - subdivision_2_name: str | None = None, - city_name: str | None = None, - metro_code: int | None = None, - time_zone: str | None = None, - is_in_european_union: bool | None = None, - ) -> IPGeoname: - - instance = IPGeoname.model_validate( - { - "geoname_id": geoname_id, - "continent_code": continent_code, - "continent_name": continent_name, - "country_iso": country_iso, - "country_name": country_name, - "subdivision_1_iso": subdivision_1_iso, - "subdivision_1_name": subdivision_1_name, - "subdivision_2_iso": subdivision_2_iso, - "subdivision_2_name": subdivision_2_name, - "city_name": city_name, - "metro_code": metro_code, - "time_zone": time_zone, - "is_in_european_union": is_in_european_union, - } - ) - - self.pg_config.execute_write( - query=""" - INSERT INTO thl_geoname - ( geoname_id, continent_code, continent_name, - country_iso, country_name, - subdivision_1_iso, subdivision_1_name, - subdivision_2_iso, subdivision_2_name, - city_name, metro_code, time_zone, is_in_european_union, - updated - ) - VALUES ( - %(geoname_id)s, %(continent_code)s, %(continent_name)s, - %(country_iso)s, %(country_name)s, - %(subdivision_1_iso)s, %(subdivision_1_name)s, - %(subdivision_2_iso)s, %(subdivision_2_name)s, - %(city_name)s, %(metro_code)s, %(time_zone)s, - %(is_in_european_union)s, %(updated)s - ) - ON CONFLICT (geoname_id) DO NOTHING; - """, - params=instance.model_dump(mode="json"), - ) - - return instance - - def get_by_id(self, geoname_id: PositiveInt) -> IPGeoname: - return self.fetch_geoname_ids(filter_ids=[geoname_id])[0] - - def fetch_geoname_ids( - self, - filter_ids: list[PositiveInt], - ) -> list[IPGeoname]: - - if len(filter_ids) == 0: - return [] - - with self.pg_config.make_connection() as conn, conn.cursor() as c: - res = [] - for chunk in chunked(filter_ids, 500): - res.extend( - self.fetch_geoname_ids_( - c=c, - filter_ids=chunk, - ) - ) - return res - - def fetch_geoname_ids_( - self, - c: Cursor, - filter_ids: list[PositiveInt], - ) -> list[IPGeoname]: - - assert len(filter_ids) <= 500, "chunk me" - - c.execute( - query=""" - SELECT g.geoname_id, - g.continent_code, g.continent_name, - g.country_iso, g.country_name, - g.subdivision_1_iso, g.subdivision_1_name, - g.subdivision_2_iso, g.subdivision_2_name, - g.city_name, g.metro_code, - g.time_zone, g.is_in_european_union, - g.updated - FROM thl_geoname AS g - WHERE g.geoname_id = ANY(%s); - """, - params=[filter_ids], - ) - return [IPGeoname.from_mysql(i) for i in c.fetchall()] - - -class IPInformationManager(PostgresManager): - - def create_basic( - self, - ip: IPvAnyAddressStr, - geoname_id: PositiveInt, - country_iso: str, - registered_country_iso: str, - ) -> IPInformation: - instance = IPInformation.model_validate( - { - "ip": ip, - "geoname_id": geoname_id, - "country_iso": country_iso, - "registered_country_iso": registered_country_iso, - } - ) - instance.normalize_ip() - self.pg_config.execute_write( - query=""" - INSERT INTO thl_ipinformation - (ip, country_iso, registered_country_iso, geoname_id, updated) - VALUES (%(ip)s, %(country_iso)s, %(registered_country_iso)s, - %(geoname_id)s, %(updated)s) - ON CONFLICT (ip) DO NOTHING; - """, - params=instance.model_dump(mode="json"), - ) - return instance - - def create( - self, - ip: IPvAnyAddressStr, - geoname_id: PositiveInt | None = None, - country_iso: str | None = None, - registered_country_iso: str | None = None, - is_anonymous: bool | None = None, - is_anonymous_vpn: bool | None = None, - is_hosting_provider: bool | None = None, - is_public_proxy: bool | None = None, - is_tor_exit_node: bool | None = None, - is_residential_proxy: bool | None = None, - autonomous_system_number: PositiveInt | None = None, - autonomous_system_organization: str | None = None, - domain: str | None = None, - isp: str | None = None, - mobile_country_code: str | None = None, - mobile_network_code: str | None = None, - network: str | None = None, - organization: str | None = None, - static_ip_score: float | None = None, - user_type: AccessType | None = None, - postal_code: str | None = None, - latitude: Decimal | None = None, - longitude: Decimal | None = None, - accuracy_radius: int | None = None, - ) -> IPInformation: - - instance = IPInformation.model_validate( - { - "ip": ip, - "geoname_id": geoname_id, - "country_iso": country_iso, - "registered_country_iso": registered_country_iso, - "is_anonymous": is_anonymous, - "is_anonymous_vpn": is_anonymous_vpn, - "is_hosting_provider": is_hosting_provider, - "is_public_proxy": is_public_proxy, - "is_tor_exit_node": is_tor_exit_node, - "is_residential_proxy": is_residential_proxy, - "autonomous_system_number": autonomous_system_number, - "autonomous_system_organization": autonomous_system_organization, - "domain": domain, - "isp": isp, - "mobile_country_code": mobile_country_code, - "mobile_network_code": mobile_network_code, - "network": network, - "organization": organization, - "static_ip_score": static_ip_score, - "user_type": user_type, - "postal_code": postal_code, - "latitude": latitude, - "longitude": longitude, - "accuracy_radius": accuracy_radius, - } - ) - instance.normalize_ip() - - self.pg_config.execute_write( - query=""" - INSERT INTO thl_ipinformation - ( ip, geoname_id, - country_iso, registered_country_iso, - is_anonymous, is_anonymous_vpn, - is_hosting_provider, is_public_proxy, - is_tor_exit_node, is_residential_proxy, - autonomous_system_number, autonomous_system_organization, - domain, isp, - mobile_country_code, mobile_network_code, - network, organization, static_ip_score, - user_type, postal_code, latitude, longitude, - accuracy_radius, - updated - ) - VALUES ( - %(ip)s, %(geoname_id)s, - %(country_iso)s, %(registered_country_iso)s, - %(is_anonymous)s, %(is_anonymous_vpn)s, - %(is_hosting_provider)s, %(is_public_proxy)s, - %(is_tor_exit_node)s, %(is_residential_proxy)s, - %(autonomous_system_number)s, %(autonomous_system_organization)s, - %(domain)s, %(isp)s, - %(mobile_country_code)s, %(mobile_network_code)s, - %(network)s, %(organization)s, %(static_ip_score)s, - %(user_type)s, %(postal_code)s, %(latitude)s, %(longitude)s, - %(accuracy_radius)s, - %(updated)s - ) - ON CONFLICT (ip) DO NOTHING; - """, - params=instance.model_dump(mode="json"), - ) - - return instance - - def create_or_update(self, ipinfo: IPInformation): - ipinfo.normalize_ip() - keys = [key for key, field in ipinfo.model_fields.items() if not field.exclude] - data = ipinfo.model_dump_mysql() - - keys_str = ", ".join(keys) - values_str = ", ".join([f"%({k})s" for k in keys]) - update_cols = set(keys) - {"ip"} - update_str = ", ".join([f"{k} = EXCLUDED.{k}" for k in update_cols]) - - query = f""" - INSERT INTO thl_ipinformation ({keys_str}) - VALUES ({values_str}) - ON CONFLICT (ip) DO UPDATE - SET {update_str} - """ - self.pg_config.execute_write(query, params=data) - - def get_ip_info(self, ip: IPvAnyAddressStr) -> IPInformation | None: - res = self.fetch_ip_information(filter_ips=[ip]) - if len(res) != 1: - return None - - return res[0] - - def fetch_ip_information( - self, - filter_ips: list[IPvAnyAddressStr], - ) -> list[IPInformation]: - - if len(filter_ips) == 0: - return [] - - with self.pg_config.make_connection() as conn, conn.cursor() as c: - res = [] - for chunk in chunked(filter_ips, 500): - res.extend( - self.fetch_ip_information_( - c=c, - filter_ips=chunk, - ) - ) - return res - def fetch_ip_information_( - self, - c: Cursor, - filter_ips: list[IPvAnyAddressStr], - ) -> list[IPInformation]: - """ - IPs are converted to normalized form (/64 network exploded) for DB lookup, - and are then matched back to the original queried form for return. - e.g. '2600:1700:ece0:9410:055d:faf3:c15d:06e4' is passed in, - it gets converted to '2600:1700:ece0:9410:0000:0000:0000:0000' for db lookup, - the result gets - """ +from grip_client import GRIPMMDBClient - assert len(filter_ips) <= 500, "chunk me" - normalized_ip_lookup = {ip: normalize_ip(ip)[0] for ip in filter_ips} - normalized_ips = set(normalized_ip_lookup.values()) +from generalresearch.managers.base import Manager +from generalresearch.models.custom_types import IPvAnyAddressStr +from generalresearch.models.thl.ipinfo import GeoIPInformation - c.execute( - query=""" - SELECT i.ip, i.geoname_id, - i.country_iso, i.registered_country_iso, - i.is_anonymous, i.is_anonymous_vpn, i.is_hosting_provider, - i.is_public_proxy, i.is_tor_exit_node, i.is_residential_proxy, - i.autonomous_system_number, i.autonomous_system_organization, - i.domain, i.isp, - i.mobile_country_code, i.mobile_network_code, - i.network, i.organization, - i.static_ip_score, i.user_type, i.postal_code, - i.latitude, i.longitude, i.accuracy_radius, - i.updated - FROM thl_ipinformation AS i - WHERE i.ip = ANY(%s) - """, - params=[list(normalized_ips)], - ) +class GeoIpInfoManager(Manager): + def __init__(self, grip_token: str, **kwargs) -> None: + self.grip_mmdb = GRIPMMDBClient(token=grip_token, **kwargs) - return [IPInformation.from_mysql(i) for i in c.fetchall()] - - @staticmethod - def monitor_ipinformation(pg_config: PostgresConfig) -> None: - """Continually check our IPInformation table to ensure location information - is being saved properly""" - - # Check the percentage of IPs that don't have a country_iso in the past 12 hours - # Split query is 1000x faster - query = """ - SELECT COUNT(*) AS numerator - FROM thl_ipinformation - WHERE updated >= NOW() - INTERVAL '12 hours' - AND country_iso IS NULL; - """ - # numerator = list(pg_config.execute_sql_query(query=query))[0]["numerator"] - - query = """ - SELECT COUNT(1) AS denominator - FROM thl_ipinformation - WHERE updated >= NOW() - INTERVAL '12 hours' - """ - denominator = next(iter(pg_config.execute_sql_query(query=query)))[ - "denominator" - ] - if denominator == 0: - pass - - # percent_empty = numerator / (denominator or 1) - # TODO: Post to telegraf / grafana - - -class GeoIpInfoManager(PostgresManagerWithRedis): - - def get(self, ip_address: IPvAnyAddressStr) -> GeoIPInformation | None: - res = self.get_cache(ip_address) - if res: - return res - res = self.get_mysql_if_exists(ip_address) - if res: - self.set_cache(res) - return res + def get(self, ip_address: IPvAnyAddressStr) -> GeoIPInformation: + d = self.get_multi({ip_address}) + return d[ip_address] def get_multi( self, ip_addresses: Collection[IPvAnyAddressStr] - ) -> dict[IPvAnyAddressStr, GeoIPInformation | None]: + ) -> dict[IPvAnyAddressStr, GeoIPInformation]: if not ip_addresses: return {} - # To deploy this, we still have (for the next 28 days) users who's - # ipv6 history was looked up and saved using the full /128. We need - # to pull those if the /64 doesn't exist. - # See notes in get_cache_multi & get_mysql_multi - res = self.get_cache_multi(ip_addresses=ip_addresses) - missing_ips = {k for k, v in res.items() if v is None and k in ip_addresses} - res_mysql = self.get_mysql_multi(ips=missing_ips) - self.set_cache_multi({k: v for k, v in res_mysql.items() if v}) - res.update(res_mysql) - return res - - def set_cache_multi( - self, ipinfo_map: dict[IPvAnyAddressStr, GeoIPInformation] - ) -> None: - """Set multiple GeoIPInformation objects in Redis in one call.""" - if not ipinfo_map: - return - - pipe = self.redis_client.pipeline(transaction=False) - expire_seconds = 3 * 24 * 3600 - for ip, ipinfo in ipinfo_map.items(): - pipe.set( - self.get_cache_key(ip), - ipinfo.model_dump_json(), - ex=expire_seconds, - ) - pipe.execute() - - @staticmethod - def compress_ip(ip: str) -> str: - """ - To support looking up an ip in the db before we switched - to using the exploded form. (remove me 28 days after 2025-11-15) - """ - addr = ipaddress.ip_address(ip) - if addr.version == 4: - return str(addr) - return addr.compressed - - def get_cache_multi( - self, ip_addresses: Collection[IPvAnyAddressStr] - ) -> dict[IPvAnyAddressStr, GeoIPInformation | None]: - """Get multiple GeoIPInformation objects from Redis in one call. - - Returns a dict mapping IP address -> GeoIPInformation (or None if not in cache). - """ - if not ip_addresses: - return {} - # We must do it like this b/c we could have multiple /128 ips that normalize - # to the same normalized /64 ip, and we don't want to "loose" them. - ip_norm_lookup = {ip: normalize_ip(ip) for ip in ip_addresses} - normalized_ips = {v[0] for v in ip_norm_lookup.values()} - # also lookup exact matches (can remove this 28 days from 2025-11-15) - normalized_ips.update(ip_addresses) - # also lookup compressed form ... (remove me also) - normalized_ips.update({self.compress_ip(ip) for ip in ip_addresses}) - - keys = [self.get_cache_key(ip) for ip in normalized_ips] - res = self.redis_client.mget(keys) - res = [GeoIPInformation.model_validate_json(raw) for raw in res if raw] - gs = {x.ip: x for x in res} - - res2 = {} - for ip, (normalized_ip, lookup_prefix) in ip_norm_lookup.items(): - if normalized_ip not in gs: - # try the non-normalized (remove me also 28 days from 2025-11-15) - if ip in gs: - res2[ip] = gs[ip].model_copy() - continue - res2[ip] = None - continue - g = gs[normalized_ip] - g.ip = ip - g.lookup_prefix = lookup_prefix - res2[g.ip] = g.model_copy() - return res2 - - def get_cache_key(self, ip_address: IPvAnyAddressStr) -> str: - return self.cache_prefix + f"thl:GeoIpInfoManager:{ip_address}" - - def clear_cache(self, ip_address: IPvAnyAddressStr) -> None: - # typically for testing - self.redis_client.delete(self.get_cache_key(ip_address=ip_address)) - - def set_cache(self, ipinfo: GeoIPInformation): - ipinfo = ipinfo.model_copy() - ipinfo.normalize_ip() - data = ipinfo.model_dump_json() - return self.redis_client.set( - self.get_cache_key(ip_address=ipinfo.ip), data, ex=3 * 24 * 3600 - ) - - def get_cache(self, ip_address: IPvAnyAddressStr) -> GeoIPInformation | None: - normalized_ip, lookup_prefix = normalize_ip(ip_address) - res: str = self.get_cache_raw(normalized_ip) - if not res: - return None - g = GeoIPInformation.model_validate_json(res) - g.ip = ip_address - g.lookup_prefix = lookup_prefix - return g - - def get_cache_raw(self, ip_address: IPvAnyAddressStr) -> str: - return self.redis_client.get(self.get_cache_key(ip_address=ip_address)) - - def get_mysql_if_exists(self, ip_address: IPvAnyAddressStr): - try: - return self.get_mysql(ip_address=ip_address) - except AssertionError: - return None - - def get_mysql_raw(self, ip_address: IPvAnyAddressStr): - query = """ - SELECT - geo.geoname_id, - geo.continent_name, - LOWER(geo.continent_code) AS continent_code, - geo.country_name, - LOWER(geo.country_iso) AS geo_country_iso, - geo.subdivision_1_iso, - geo.subdivision_1_name, - geo.subdivision_2_iso, - geo.subdivision_2_name, - geo.city_name, - geo.metro_code, - geo.time_zone, - geo.is_in_european_union, - LOWER(ipinfo.country_iso) AS country_iso, - ipinfo.registered_country_iso, - ipinfo.is_anonymous, - ipinfo.is_anonymous_vpn, - ipinfo.is_hosting_provider, - ipinfo.is_public_proxy, - ipinfo.is_tor_exit_node, - ipinfo.is_residential_proxy, - ipinfo.autonomous_system_number, - ipinfo.autonomous_system_organization, - ipinfo.domain, - ipinfo.isp, - ipinfo.mobile_country_code, - ipinfo.mobile_network_code, - ipinfo.network, - ipinfo.organization, - ipinfo.static_ip_score, - ipinfo.user_type, - ipinfo.postal_code, - CAST(ipinfo.latitude AS float) AS latitude, - CAST(ipinfo.longitude AS float) AS longitude, - ipinfo.accuracy_radius, - ipinfo.ip, - ipinfo.updated - FROM thl_ipinformation AS ipinfo - LEFT JOIN thl_geoname AS geo - ON ipinfo.geoname_id = geo.geoname_id - WHERE ipinfo.ip = %s - """ - res = self.pg_config.execute_sql_query(query=query, params=[ip_address]) - assert len(res) == 1 - d = res[0] - if d.get("geo_country_iso") and (d["geo_country_iso"] != d["country_iso"]): - raise ValueError( - f'mismatch between ipinfo country {d["country_iso"]} and geoname country {d["geo_country_iso"]}' + ips = set(ip_addresses) + d = {ip: self.grip_mmdb.lookup(ip) for ip in ips} + d = { + ip: GeoIPInformation( + ip=ip, + country_iso=v.country.country_iso.lower() + if v.country.country_iso + else None, + is_anonymous=v.anonymous.is_anonymous, + autonomous_system_number=v.asn.asn, + autonomous_system_organization=v.asn.network_operator, + access_type=v.asn.access_type, ) + for ip, v in d.items() + } return d - - def get_mysql(self, ip_address: IPvAnyAddressStr): - normalized_ip, lookup_prefix = normalize_ip(ip_address) - d = self.get_mysql_raw(normalized_ip) - g = GeoIPInformation.from_mysql(d) - g.ip = ip_address - g.lookup_prefix = lookup_prefix - return g - - def recreate_cache(self, ip_address: IPvAnyAddressStr) -> GeoIPInformation: - res = self.get_mysql(ip_address) - self.set_cache(res) - return res - - def get_mysql_multi( - self, - ips: Collection[IPvAnyAddressStr], - ) -> dict[IPvAnyAddressStr, GeoIPInformation | None]: - - if len(ips) == 0: - return {} - - with self.pg_config.make_connection() as conn, conn.cursor() as c: - res = {} - for chunk in chunked(ips, 500): - inner = self.get_mysql_multi_chunk( - c=c, - ips=chunk, - ) - res.update(inner) - return res - - def get_mysql_multi_chunk( - self, - c: Cursor, - ips: list[IPvAnyAddressStr], - ) -> dict[IPvAnyAddressStr, GeoIPInformation | None]: - - assert len(ips) <= 500, "chunk me" - - # We must do it like this b/c we could have multiple /128 ips that normalize - # to the same normalized /64 ip, and we don't want to "loose" them. - ip_norm_lookup = {ip: normalize_ip(ip) for ip in ips} - normalized_ips = {v[0] for v in ip_norm_lookup.values()} - # also lookup exact matches (can remove this 28 days from 2025-11-15) - normalized_ips.update(ips) - # also lookup compressed form ... (remove me also) - normalized_ips.update({self.compress_ip(ip) for ip in ips}) - - c.execute( - query=""" - SELECT - geo.geoname_id, - geo.continent_name, - LOWER(geo.continent_code) AS continent_code, - geo.country_name, - LOWER(geo.country_iso) AS geo_country_iso, - geo.subdivision_1_iso, - geo.subdivision_1_name, - geo.subdivision_2_iso, - geo.subdivision_2_name, - geo.city_name, - geo.metro_code, - geo.time_zone, - geo.is_in_european_union, - LOWER(ipinfo.country_iso) AS country_iso, - ipinfo.registered_country_iso, - ipinfo.is_anonymous, - ipinfo.is_anonymous_vpn, - ipinfo.is_hosting_provider, - ipinfo.is_public_proxy, - ipinfo.is_tor_exit_node, - ipinfo.is_residential_proxy, - ipinfo.autonomous_system_number, - ipinfo.autonomous_system_organization, - ipinfo.domain, - ipinfo.isp, - ipinfo.mobile_country_code, - ipinfo.mobile_network_code, - ipinfo.network, - ipinfo.organization, - ipinfo.static_ip_score, - ipinfo.user_type, - ipinfo.postal_code, - CAST(ipinfo.latitude AS float) AS latitude, - CAST(ipinfo.longitude AS float) AS longitude, - ipinfo.accuracy_radius, - ipinfo.ip, - ipinfo.updated - FROM thl_ipinformation AS ipinfo - LEFT JOIN thl_geoname AS geo - ON ipinfo.geoname_id = geo.geoname_id - WHERE ipinfo.ip = ANY(%s) - """, - params=[list(normalized_ips)], - ) - - res = c.fetchall() - for d in res: - if d.get("geo_country_iso") and (d["geo_country_iso"] != d["country_iso"]): - raise ValueError( - f'mismatch between ipinfo country {d["country_iso"]} and geoname country {d["geo_country_iso"]}' - ) - - gs = [GeoIPInformation.from_mysql(i) for i in res] - gs = {g.ip: g for g in gs} - res2 = {} - - for ip, (normalized_ip, lookup_prefix) in ip_norm_lookup.items(): - if normalized_ip not in gs: - # also can remove 28 days after 2025-11-15 - if ip in gs: - res2[ip] = gs[ip].model_copy() - continue - res2[ip] = None - continue - g = gs[normalized_ip] - g.ip = ip - g.lookup_prefix = lookup_prefix - res2[g.ip] = g.model_copy() - return res2 diff --git a/generalresearch/managers/thl/payout.py b/generalresearch/managers/thl/payout.py index 59dc2e9..4014cf8 100644 --- a/generalresearch/managers/thl/payout.py +++ b/generalresearch/managers/thl/payout.py @@ -34,10 +34,6 @@ from generalresearch.models.thl.payout import ( PayoutEvent, UserPayoutEvent, ) -from generalresearch.models.thl.wallet.cashout_method import ( - CashMailOrderData, - CashoutRequestInfo, -) from generalresearch.models.thl.wallet.definitions import PayoutType if TYPE_CHECKING: @@ -105,225 +101,6 @@ class PayoutEventManager(PostgresManagerWithRedis): conn.commit() -class UserPayoutEventManager(PayoutEventManager): - def get_by_uuid(self, pe_uuid: UUIDStr) -> UserPayoutEvent: - - res = self.pg_config.execute_sql_query( - query=""" - 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, - -- User Payout specific - ac.name as description, - la.reference_type as account_reference_type, - la.reference_uuid as account_reference_uuid - 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 - WHERE ep.uuid = %s - """, - params=[pe_uuid], - ) - - assert len(res) == 1, f"{pe_uuid} expected 1 result, got {len(res)}" - - d = res[0] - pe = UserPayoutEvent.model_validate(d) - if pe.order_data and pe.payout_type == PayoutType.CASH_IN_MAIL: - pe.order_data = CashMailOrderData.model_validate(pe.order_data) - - return pe - - def get_payout_detail(self, pe_uuid: UUIDStr) -> CashoutRequestInfo: - # This gets the payout event, and then extracts information for - # the purposes of returning to the user. - pe = self.get_by_uuid(pe_uuid=pe_uuid) - - transaction_info = {} - order: dict[str, Any] = pe.order_data - if pe.payout_type == PayoutType.TANGO and pe.status == PayoutStatus.COMPLETE: - reward = order["reward"] - if "credentialList" in reward: - reward["credential_list"] = reward.pop("credentialList") - if "redemptionInstructions" in reward: - reward["redemption_instructions"] = reward.pop("redemptionInstructions") - transaction_info = order["reward"] - elif pe.payout_type == PayoutType.PAYPAL and pe.status == PayoutStatus.COMPLETE: - info = {"transaction_id": order["transaction_id"]} - transaction_info = info - elif ( - pe.payout_type == PayoutType.CASH_IN_MAIL - and pe.status == PayoutStatus.COMPLETE - ): - transaction_info = pe.order_data.model_dump(mode="json") - - return CashoutRequestInfo( - id=pe_uuid, - status=pe.status, - description=pe.description, - transaction_info=transaction_info, - message="", - ) - - def filter_by( - self, - reference_uuid: 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, - cashout_method_uuids: Collection[UUIDStr] | None = None, - cashout_types: Collection[PayoutType] | None = None, - statuses: Collection[PayoutStatus] | None = None, - ) -> list[UserPayoutEvent]: - """Try to retrieve payout events by the product_id/user_uuid, amount, - and optionally timestamp. - - 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. - - *** IT IS ONLY FOR USER PAYOUTS *** - - Note: what used to be in thl-grpcs "ListCashoutRequests" calling - "list_cashout_requests" was merged into this. - """ - args = [] - 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 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) - if amount: - filters.append("ep.amount = %s") - args.append(amount) - if created: - filters.append("ep.created = %s") - args.append(created.replace(tzinfo=None)) - 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_method_uuids: - filters.append("cashout_method_uuid = ANY(%s)") - args.append(cashout_method_uuids) - 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]) - - assert len(filters) > 0, "must pass at least 1 filter" - filter_str = "WHERE " + " AND ".join(filters) - - res = self.pg_config.execute_sql_query( - query=f""" - SELECT - ep.uuid, ep.debit_account_uuid, - ep.created, ep.amount, ep.status, - ep.ext_ref_id, ep.payout_type, ep.cashout_method_uuid, - ep.order_data::jsonb, - ep.request_data::jsonb, - ac.name as description, - la.reference_type as account_reference_type, - la.reference_uuid as account_reference_uuid - 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 - {filter_str} - """, - params=args, - ) - - pes = [] - for d in res: - pes.append(UserPayoutEvent.model_validate(d)) - return pes - - def create( - self, - debit_account_uuid: UUIDStr, - cashout_method_uuid: UUIDStr, - payout_type: PayoutType, - amount: PositiveInt, - # --- Optional: Default / Default Factory --- - uuid: UUIDStr | None = None, - status: PayoutStatus | None = None, - created: AwareDatetimeISO | None = None, - request_data: dict[str, Any] | None = None, - # --- Optional: None --- - account_reference_type: str | None = None, - account_reference_uuid: UUIDStr | None = None, - description: str | None = None, - ext_ref_id: str | None = None, - order_data: dict[str, Any] | CashMailOrderData | None = None, - ) -> UserPayoutEvent: - - payout_event = UserPayoutEvent( - uuid=uuid or uuid4().hex, - debit_account_uuid=debit_account_uuid, - account_reference_type=account_reference_type, - account_reference_uuid=account_reference_uuid, - cashout_method_uuid=cashout_method_uuid, - description=description, - created=created or datetime.now(tz=UTC), - amount=amount, - status=status or PayoutStatus.PENDING, - ext_ref_id=ext_ref_id, - payout_type=payout_type, - request_data=request_data or {}, - order_data=order_data, - ) - d = payout_event.model_dump_postgres() - - with self.pg_config.make_connection() as conn: - with conn.cursor() as c: - c.execute( - query=""" - INSERT INTO event_payout ( - uuid, debit_account_uuid, created, - cashout_method_uuid, amount, status, - ext_ref_id, payout_type, order_data, - request_data - ) VALUES ( - %(uuid)s, %(debit_account_uuid)s, %(created)s, - %(cashout_method_uuid)s, %(amount)s, %(status)s, - %(ext_ref_id)s, %(payout_type)s, %(order_data)s, - %(request_data)s - ); - """, - params=d, - ) - assert c.rowcount == 1, f"expected 1 row inserted, got {c.rowcount}" - conn.commit() - - return payout_event - class BrokerageProductPayoutEventManager(PayoutEventManager): # This is what makes a PayoutEvent a Brokerage Product Payout diff --git a/generalresearch/managers/thl/userhealth.py b/generalresearch/managers/thl/userhealth.py index b986256..0df67aa 100644 --- a/generalresearch/managers/thl/userhealth.py +++ b/generalresearch/managers/thl/userhealth.py @@ -6,7 +6,6 @@ from datetime import UTC, datetime, timedelta from itertools import zip_longest from typing import TYPE_CHECKING, Any -import faker from pydantic import NonNegativeInt, PositiveInt from generalresearch.decorators import LOG @@ -14,16 +13,17 @@ from generalresearch.managers.base import ( PostgresManager, PostgresManagerWithRedis, ) -from generalresearch.managers.thl.ipinfo import GeoIpInfoManager from generalresearch.models.thl.user_iphistory import ( IPRecord, UserIPHistory, UserIPRecord, ) +from generalresearch.models.thl.user_ref import UserRef from generalresearch.models.thl.userhealth import AuditLog if TYPE_CHECKING: from generalresearch.managers.base import Permission + from generalresearch.managers.thl.ipinfo import GeoIpInfoManager from generalresearch.models.custom_types import IPvAnyAddressStr from generalresearch.models.thl.product import Product from generalresearch.models.thl.user import User @@ -31,46 +31,18 @@ if TYPE_CHECKING: from generalresearch.pg_helper import PostgresConfig from generalresearch.redis_helper import RedisConfig -fake = faker.Faker() - class UserIpHistoryManager(PostgresManagerWithRedis): - def __init__( - self, - pg_config: PostgresConfig, - redis_config: RedisConfig, - permissions: Collection[Permission] | None = None, - cache_prefix: str | None = None, - ): - super().__init__( - pg_config=pg_config, - redis_config=redis_config, - permissions=permissions, - cache_prefix=cache_prefix, - ) - self.geoipinfo_manager = GeoIpInfoManager( - pg_config=pg_config, - redis_config=redis_config, - cache_prefix=cache_prefix, - ) - def get_redis_key(self, user_id: int) -> str: - return f"generalreserach:user-ip-history:{user_id}" + return f"generalreserach:user-ip-history-v2:{user_id}" def get_user_ip_records_sql(self, user_id: int) -> list[UserIPRecord]: # The IP metadata is ONLY for the 'ip', NOT for any forwarded ips. # This might get called immediately after a write, so use the non-rr res = self.pg_config.execute_sql_query( query=""" - SELECT iph.ip, iph.created, iph.user_id, - geo.subdivision_1_iso, - ipinfo.country_iso, - ipinfo.is_anonymous + SELECT iph.ip, iph.created FROM userhealth_iphistory iph - LEFT JOIN thl_ipinformation AS ipinfo - ON iph.ip = ipinfo.ip - LEFT JOIN thl_geoname AS geo - ON ipinfo.geoname_id = geo.geoname_id WHERE iph.user_id = %s AND created > NOW() - INTERVAL '28 days' ORDER BY iph.created DESC @@ -95,76 +67,96 @@ class UserIpHistoryManager(PostgresManagerWithRedis): value = iph.model_dump_json() self.redis_client.set(self.get_redis_key(user_id), value, ex=3 * 24 * 3600) - def recreate_user_ip_history_cache(self, user_id: int) -> None: - self.delete_user_ip_history_cache(user_id=user_id) - records = self.get_user_ip_records_sql(user_id=user_id) + def recreate_user_ip_history_cache(self, user: UserRef) -> None: + self.delete_user_ip_history_cache(user_id=user.user_id) + records = self.get_user_ip_records_sql(user_id=user.user_id) # todo: we may get dns records from somewhere else here ... - iph = UserIPHistory(user_id=user_id, ips=records) - self.set_user_ip_history_cache(user_id=user_id, iph=iph) + iph = UserIPHistory(user=user, ips=records) + self.set_user_ip_history_cache(user_id=user.user_id, iph=iph) + + def get_user_ip_history( + self, user: UserRef | User, geoip_info_manager: GeoIpInfoManager | None = None + ) -> UserIPHistory: + user = user if isinstance(user, UserRef) else user.to_user_ref() - def get_user_ip_history(self, user_id: int) -> UserIPHistory: - assert isinstance(user_id, int) - iph = self.get_user_ip_history_cache(user_id=user_id) + iph = self.get_user_ip_history_cache(user_id=user.user_id) if iph: LOG.debug(f"get_user_ip_history got in cache: {iph.model_dump_json()}") - else: LOG.debug("get_user_ip_history cache not found, using mysql") - records = self.get_user_ip_records_sql(user_id=user_id) + records = self.get_user_ip_records_sql(user_id=user.user_id) # todo: we may get dns records from somewhere else here ... - iph = UserIPHistory(user_id=user_id, ips=records) - self.set_user_ip_history_cache(user_id=user_id, iph=iph) - - iph.enrich_ips(pg_config=self.pg_config, redis_config=self.redis_config) + iph = UserIPHistory(user=user, ips=records) + self.set_user_ip_history_cache(user_id=user.user_id, iph=iph) + if geoip_info_manager: + iph.enrich_ips(geoip_info_manager=geoip_info_manager) return iph - def get_user_latest_ip(self, user: User, exclude_anon: bool = False) -> str | None: - record = self.get_user_latest_ip_record(user=user, exclude_anon=exclude_anon) - if record: - return record.ip - return None - def get_user_latest_ip_record( - self, user: User, exclude_anon: bool = False + self, + user: UserRef | User, + exclude_anon: bool = False, + geoip_info_manager: GeoIpInfoManager | None = None, ) -> UserIPRecord | None: - iphistory = self.get_user_ip_history(user_id=user.user_id) - - if iphistory.ips: - if exclude_anon: - return next( - filter( - lambda x: not x.information.is_anonymous, - iphistory.ips[::-1], - ), - None, - ) - else: - return iphistory.ips[-1] + iphistory = self.get_user_ip_history( + user=user, geoip_info_manager=geoip_info_manager + ) + if not iphistory or not iphistory.ips: + return None + + # Note: UserIPHistory.ips is sorted by 'created DESC' !!! + # This logic was changed at some point? Or the py-utils + # get_user_latest_ip_record was changed. Please be careful here... + if exclude_anon: + assert geoip_info_manager is not None, "Must pass geoip_info_manager" + for ipr in iphistory.ips: + if not ipr.information.is_anonymous: + return ipr + else: + ipr = iphistory.ips[0] + return ipr + def get_user_latest_ip( + self, + user: UserRef | User, + exclude_anon: bool = False, + geoip_info_manager: GeoIpInfoManager | None = None, + ) -> str | None: + record = self.get_user_latest_ip_record( + user=user, exclude_anon=exclude_anon, geoip_info_manager=geoip_info_manager + ) + if record: + return record.ip return None def get_user_latest_country( - self, user: User, exclude_anon: bool = False + self, + user: UserRef | User, + geoip_info_manager: GeoIpInfoManager, + exclude_anon: bool = False, ) -> str | None: """Get the country the user is in, based off their latest ip.""" - ipr = self.get_user_latest_ip_record(user, exclude_anon=exclude_anon) + ipr = self.get_user_latest_ip_record( + user, geoip_info_manager=geoip_info_manager, exclude_anon=exclude_anon + ) # The ipr.information should exist, but it is possible the user has # no IP history at all, so the record is None return ipr.country_iso if ipr is not None else None - def is_user_anonymous(self, user: User) -> bool | None: + def is_user_anonymous( + self, user: UserRef | User, geoip_info_manager: GeoIpInfoManager + ) -> bool | None: # Get the user's latest ip. is it marked as anonymous? - # Note: it is possible we only did a "basic" lookup of this IP so - # we don't know if they are anonymous. Default to False # Return None if the user has no IP history at all - ipr = self.get_user_latest_ip_record(user) + ipr = self.get_user_latest_ip_record( + user, geoip_info_manager=geoip_info_manager + ) if ipr: return ipr.is_anonymous if ipr.is_anonymous is not None else False return None class IPRecordManager(PostgresManagerWithRedis): - def __init__( self, pg_config: PostgresConfig, @@ -187,7 +179,7 @@ class IPRecordManager(PostgresManagerWithRedis): def create_unpack( self, - user_id: PositiveInt, + user: UserRef, ip: IPvAnyAddressStr, forwarded_ips: list[str], ) -> IPRecord: @@ -196,11 +188,11 @@ class IPRecordManager(PostgresManagerWithRedis): padded = list(forwarded_ips) + [None] * (6 - len(forwarded_ips)) - return self.create(user_id, ip, *padded) + return self.create(user, ip, *padded) def create( self, - user_id: PositiveInt, + user: UserRef, ip: IPvAnyAddressStr, forwarded_ip1: IPvAnyAddressStr, forwarded_ip2: IPvAnyAddressStr, @@ -211,7 +203,7 @@ class IPRecordManager(PostgresManagerWithRedis): ) -> IPRecord: data = { - "user_id": user_id, + "user_id": user.user_id, "ip": ipaddress.ip_address(ip).exploded, "created": datetime.now(tz=UTC), } @@ -253,11 +245,12 @@ class IPRecordManager(PostgresManagerWithRedis): """, params=data, ) - self.recreate_user_ip_history_cache(user_id=user_id) + self.recreate_user_ip_history_cache(user=user) return IPRecord.from_mysql(data) - def get_user_latest_ip_record(self, user: User) -> IPRecord | None: + def get_user_latest_ip_record(self, user: UserRef | User) -> IPRecord | None: + user = user if isinstance(user, UserRef) else user.to_user_ref() res = self.filter_ip_records(user_ids=[user.user_id], limit=1) if res: return res[0] @@ -317,14 +310,11 @@ class IPRecordManager(PostgresManagerWithRedis): return [IPRecord.from_mysql(i) for i in res] - def recreate_user_ip_history_cache(self, user_id: int): - return self.user_ip_history_manager.recreate_user_ip_history_cache( - user_id=user_id - ) + def recreate_user_ip_history_cache(self, user: UserRef): + return self.user_ip_history_manager.recreate_user_ip_history_cache(user=user) class AuditLogManager(PostgresManager): - def create( self, user_id: PositiveInt, diff --git a/generalresearch/managers/thl/wallet/__init__.py b/generalresearch/managers/thl/wallet/__init__.py index 1f0c30b..006908b 100644 --- a/generalresearch/managers/thl/wallet/__init__.py +++ b/generalresearch/managers/thl/wallet/__init__.py @@ -1,6 +1,7 @@ from decimal import Decimal from typing import TYPE_CHECKING, Any +from generalresearch.managers.thl.ipinfo import GeoIpInfoManager from generalresearch.managers.thl.wallet.approve import ( approve_amt_cashout, approve_paypal_order, @@ -34,6 +35,7 @@ def manage_pending_cashout( user_ip_history_manager: UserIpHistoryManager, user_manager: UserManager, ledger_manager: ThlLedgerManager, + geoip_info_manager:GeoIpInfoManager, order_data: dict[str, Any] | CashMailOrderData | None = None, tango_client: TangoClient | None = None, ) -> UserPayoutEvent: @@ -63,7 +65,7 @@ def manage_pending_cashout( "manage_pending_cashout called on user without managed wallet" ) assert not user.blocked, "manage_pending_cashout: Blocked user" - assert not user_ip_history_manager.is_user_anonymous(user), ( + assert not user_ip_history_manager.is_user_anonymous(user,geoip_info_manager=geoip_info_manager), ( "manage_pending_cashout: Anonymous user" ) diff --git a/generalresearch/managers/thl/wallet/cashout_tasks.py b/generalresearch/managers/thl/wallet/cashout_tasks.py new file mode 100644 index 0000000..b28b04f --- /dev/null +++ b/generalresearch/managers/thl/wallet/cashout_tasks.py @@ -0,0 +1,3 @@ + + + diff --git a/generalresearch/managers/thl/wallet/tango.py b/generalresearch/managers/thl/wallet/tango.py index aa780c3..fc6763b 100644 --- a/generalresearch/managers/thl/wallet/tango.py +++ b/generalresearch/managers/thl/wallet/tango.py @@ -1,7 +1,6 @@ from __future__ import annotations from datetime import timedelta -from decimal import Decimal from threading import Lock from typing import Any @@ -10,6 +9,11 @@ from cachetools import TTLCache, cachedmethod from generalresearch.currency import USDCent from generalresearch.managers.thl.cashout_method import CashoutMethodManager from generalresearch.managers.thl.tango_api import TangoClient +from generalresearch.models.thl.wallet.cashout_method import ( + TangoCashoutMethodData, + CashoutMethod, + TangoCashoutMethodRequestData, +) from generalresearch.models.thl.wallet.definitions import ( CURRENCY_FORMATTER, Currency, @@ -57,27 +61,26 @@ class TangoManager: return self.tango_client.get_order(tango_order_id) def make_request( - self, amount_usd: Decimal, cashout_method: Any, external_ref_id: str - ) -> dict[str, Any]: + self, amount: USDCent, cashout_method: CashoutMethod, payout_event_id: str + ) -> TangoCashoutMethodRequestData: """Build the data needed to place a Tango order.""" - assert type(amount_usd) is Decimal + assert type(amount) is USDCent utid = cashout_method.data.utid - amount: Decimal | float = amount_usd currency = cashout_method.original_currency - currency_code = getattr(currency, "value", currency) - if currency_code and currency_code != "USD": - amount = round(float(amount) / self.get_exchange_rates()[currency_code], 2) - return { - "accountIdentifier": self.tango_account_id, - "customerIdentifier": self.tango_customer_id, - "utid": utid, - "amount": str(amount), - "amount_usd": str(amount_usd), - "campaign": "300large", - "sendEmail": False, - "externalRefID": external_ref_id, - "description": self.get_name(utid), - } + if currency and currency != Currency.USD: + amount = round(float(amount) / self.get_exchange_rates()[currency], 2) + return TangoCashoutMethodRequestData.model_validate( + { + "accountIdentifier": self.tango_account_id, + "customerIdentifier": self.tango_customer_id, + "utid": utid, + "amount": str(amount), + "campaign": "300large", + "sendEmail": False, + "externalRefID": payout_event_id, + "description": self.get_name(utid), + } + ) @cachedmethod( cache=lambda self: self._name_cache, diff --git a/generalresearch/managers/thl/wallet/user_payout.py b/generalresearch/managers/thl/wallet/user_payout.py new file mode 100644 index 0000000..88505c0 --- /dev/null +++ b/generalresearch/managers/thl/wallet/user_payout.py @@ -0,0 +1,392 @@ +import uuid +from collections.abc import Collection +from datetime import UTC, datetime +from typing import Any +from uuid import uuid4 + +import slack +from pydantic import PositiveInt +from redis import Redis + +from generalresearch.currency import USDCent +from generalresearch.managers.thl.cashout_method import CashoutMethodManager +from generalresearch.managers.thl.ipinfo import GeoIpInfoManager +from generalresearch.managers.thl.ledger_manager.thl_ledger import ThlLedgerManager +from generalresearch.managers.thl.payout import PayoutEventManager +from generalresearch.managers.thl.userhealth import UserIpHistoryManager +from generalresearch.managers.thl.wallet.tango import TangoManager +from generalresearch.models.custom_types import AwareDatetimeISO, UUIDStr +from generalresearch.models.thl.definitions import PayoutStatus +from generalresearch.models.thl.payout import UserPayoutEvent +from generalresearch.models.thl.user import User +from generalresearch.models.thl.wallet.cashout_method import ( + CashMailOrderData, + CashoutMethod, + CashoutRequestInfo, + PaypalCashoutMethodRequestData, + TangoCashoutMethodRequestData, + CashMailCashoutMethodRequestData, +) +from generalresearch.models.thl.wallet.definitions import PayoutType + + +class UserPayoutEventManager(PayoutEventManager): + def get_by_uuid(self, pe_uuid: UUIDStr) -> UserPayoutEvent: + + res = self.pg_config.execute_sql_query( + query=""" + 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, + -- User Payout specific + ac.name as description, + la.reference_type as account_reference_type, + la.reference_uuid as account_reference_uuid + 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 + WHERE ep.uuid = %s + """, + params=[pe_uuid], + ) + + assert len(res) == 1, f"{pe_uuid} expected 1 result, got {len(res)}" + + d = res[0] + pe = UserPayoutEvent.model_validate(d) + if pe.order_data and pe.payout_type == PayoutType.CASH_IN_MAIL: + pe.order_data = CashMailOrderData.model_validate(pe.order_data) + + return pe + + def get_payout_detail(self, pe_uuid: UUIDStr) -> CashoutRequestInfo: + # This gets the payout event, and then extracts information for + # the purposes of returning to the user. + pe = self.get_by_uuid(pe_uuid=pe_uuid) + + transaction_info = {} + order: dict[str, Any] = pe.order_data + if pe.payout_type == PayoutType.TANGO and pe.status == PayoutStatus.COMPLETE: + reward = order["reward"] + if "credentialList" in reward: + reward["credential_list"] = reward.pop("credentialList") + if "redemptionInstructions" in reward: + reward["redemption_instructions"] = reward.pop("redemptionInstructions") + transaction_info = order["reward"] + elif pe.payout_type == PayoutType.PAYPAL and pe.status == PayoutStatus.COMPLETE: + info = {"transaction_id": order["transaction_id"]} + transaction_info = info + elif ( + pe.payout_type == PayoutType.CASH_IN_MAIL + and pe.status == PayoutStatus.COMPLETE + ): + transaction_info = pe.order_data.model_dump(mode="json") + + return CashoutRequestInfo( + id=pe_uuid, + status=pe.status, + description=pe.description, + transaction_info=transaction_info, + message="", + ) + + def filter_by( + self, + reference_uuid: 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, + cashout_method_uuids: Collection[UUIDStr] | None = None, + cashout_types: Collection[PayoutType] | None = None, + statuses: Collection[PayoutStatus] | None = None, + ) -> list[UserPayoutEvent]: + """Try to retrieve payout events by the product_id/user_uuid, amount, + and optionally timestamp. + + 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. + + *** IT IS ONLY FOR USER PAYOUTS *** + + Note: what used to be in thl-grpcs "ListCashoutRequests" calling + "list_cashout_requests" was merged into this. + """ + args = [] + 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 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) + if amount: + filters.append("ep.amount = %s") + args.append(amount) + if created: + filters.append("ep.created = %s") + args.append(created.replace(tzinfo=None)) + 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_method_uuids: + filters.append("cashout_method_uuid = ANY(%s)") + args.append(cashout_method_uuids) + 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]) + + assert len(filters) > 0, "must pass at least 1 filter" + filter_str = "WHERE " + " AND ".join(filters) + + res = self.pg_config.execute_sql_query( + query=f""" + SELECT + ep.uuid, ep.debit_account_uuid, + ep.created, ep.amount, ep.status, + ep.ext_ref_id, ep.payout_type, ep.cashout_method_uuid, + ep.order_data::jsonb, + ep.request_data::jsonb, + ac.name as description, + la.reference_type as account_reference_type, + la.reference_uuid as account_reference_uuid + 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 + {filter_str} + """, + params=args, + ) + + pes = [] + for d in res: + pes.append(UserPayoutEvent.model_validate(d)) + return pes + + def create( + self, + debit_account_uuid: UUIDStr, + cashout_method_uuid: UUIDStr, + payout_type: PayoutType, + amount: PositiveInt, + # --- Optional: Default / Default Factory --- + uuid: UUIDStr | None = None, + status: PayoutStatus | None = None, + created: AwareDatetimeISO | None = None, + request_data: dict[str, Any] | None = None, + # --- Optional: None --- + account_reference_type: str | None = None, + account_reference_uuid: UUIDStr | None = None, + description: str | None = None, + ext_ref_id: str | None = None, + order_data: dict[str, Any] | CashMailOrderData | None = None, + ) -> UserPayoutEvent: + + payout_event = UserPayoutEvent( + uuid=uuid or uuid4().hex, + debit_account_uuid=debit_account_uuid, + account_reference_type=account_reference_type, + account_reference_uuid=account_reference_uuid, + cashout_method_uuid=cashout_method_uuid, + description=description, + created=created or datetime.now(tz=UTC), + amount=amount, + status=status or PayoutStatus.PENDING, + ext_ref_id=ext_ref_id, + payout_type=payout_type, + request_data=request_data or {}, + order_data=order_data, + ) + d = payout_event.model_dump_postgres() + + with self.pg_config.make_connection() as conn: + with conn.cursor() as c: + c.execute( + query=""" + INSERT INTO event_payout ( + uuid, debit_account_uuid, created, + cashout_method_uuid, amount, status, + ext_ref_id, payout_type, order_data, + request_data + ) VALUES ( + %(uuid)s, %(debit_account_uuid)s, %(created)s, + %(cashout_method_uuid)s, %(amount)s, %(status)s, + %(ext_ref_id)s, %(payout_type)s, %(order_data)s, + %(request_data)s + ); + """, + params=d, + ) + assert c.rowcount == 1, f"expected 1 row inserted, got {c.rowcount}" + conn.commit() + + return payout_event + + def user_request_redeem( + self, + user: User, + country_iso: str, + cashout_method_id: str, + amount: USDCent, + tango_manager: TangoManager, + cashout_method_manager: CashoutMethodManager, + ledger_manager: ThlLedgerManager, + user_ip_history_manager: UserIpHistoryManager, + geoip_info_manager: GeoIpInfoManager, + redis_client: Redis, + slack_client: slack.WebClient | None = None, + ) -> UserPayoutEvent: + """ + A user has requested to be paid from their wallet balance. + :param user: User + :param cashout_method_id: the cashout method to use + :param amount_usd: In decimal USD. + """ + now = datetime.now(tz=UTC) + user.prefetch_product(pg_config=self.pg_config) + usd_exchange_rates = tango_manager.get_exchange_rates() + cashout_methods = cashout_method_manager.get_user_cashout_methods( + user, country_iso=country_iso, usd_exchange_rate=usd_exchange_rates + ) + assert cashout_method_id in cashout_methods, ( + f"cashout method '{cashout_method_id}' not found" + ) + cashout_method = cashout_methods[cashout_method_id] + + # Make sure the amount requested is allowed for this cashout method + try: + cashout_method.validate_requested_amount(amount=amount) + except ValueError as e: + # Outer function is expecting an assertion error. + raise AssertionError(str(e)) + + """ + These checks are run when a user requests any kind of payout: + - user wallet enabled on BP + - is user blocked + - is user anonymous + - does the user have a redeemable_balance <= amount requested (except for + an AMT assignment request) + """ + assert type(amount) is USDCent + user.prefetch_product(pg_config=self.pg_config) + assert user.product.user_wallet_enabled, ( + "SubmitUserCashout called on user without managed wallet" + ) + assert not user.blocked, "Blocked user requesting redemption" + assert 0 < amount <= 250_00, ( + f"Amount must be between 0 and $250.00. Got {amount.to_usd_str()}" + ) + + product = user.product + banned_countries = user.product.user_health_config.banned_countries + + assert not user_ip_history_manager.is_user_anonymous( + user, geoip_info_manager=geoip_info_manager + ), "Anonymous user requesting redemption" + ipr = user_ip_history_manager.get_user_latest_ip_record( + user, geoip_info_manager=geoip_info_manager + ) + if ipr is not None and ipr.country_iso in banned_countries: + raise AssertionError("Banned country requesting redemption") + + wallet_balance = ledger_manager.get_user_wallet_balance(user) + if product.user_wallet_config.balance_type == "wallet_balance": + redeemable_amount = wallet_balance + elif product.user_wallet_config.balance_type == "redeemable_balance": + redeemable_amount = ledger_manager.get_user_redeemable_wallet_balance(user, wallet_balance) + else: + raise ValueError(f"unexpected balance_type={product.user_wallet_config.balance_type}") + + assert amount <= redeemable_amount, ( + f"User requesting more than their redeemable balance ({amount} > {redeemable_amount}}" + ) + + # Simple dedupe mechanism. Don't allow more than 1 per user_id per minute per cashout_method. + flag_just_set = bool( + redis_client.set( + f"user_request_redeem:{user.user_id}:{cashout_method}", + 1, + nx=True, + ex=60, + ) + ) + assert flag_just_set, "User requesting more than 1 per min per cashout method" + + payout_type = cashout_method.type + + if slack_client: + slack_client.chat_postMessage( + channel="cashouts", + text=f"{user.product.name} {cashout_method.type} " + f"{user.user_id} {amount.to_usd_str()}", + ) + + pe_uuid = uuid.uuid4().hex + + if payout_type == PayoutType.TANGO: + request_data: TangoCashoutMethodRequestData = tango_manager.make_request( + amount, cashout_method, pe_uuid + ) + elif payout_type == PayoutType.PAYPAL: + request_data: PaypalCashoutMethodRequestData = make_request_paypal(cashout_method) + elif payout_type == PayoutType.CASH_IN_MAIL: + request_data: CashMailCashoutMethodRequestData = CashMailCashoutMethodRequestData.model_validate(cashout_method.data.model_dump()) + else: + raise ValueError(f"unknown {payout_type=}") + + account = ledger_manager.get_account_or_create_user_wallet(user) + + pe = self.create( + uuid=pe_uuid, + debit_account_uuid=account.uuid, + cashout_method_uuid=cashout_method_id, + amount=amount, + created=now, + payout_type=payout_type, + request_data=request_data.model_dump(mode="json"), + ) + + ledger_manager.create_tx_user_payout_request(user, payout_event=pe, created=now) + return pe + + + + + +def make_request_paypal( + cashout_method: CashoutMethod, +) -> PaypalCashoutMethodRequestData: + return PaypalCashoutMethodRequestData.model_validate( + { + "email": cashout_method.data.email, + "interface": "api", + } + ) diff --git a/generalresearch/models/thl/ipinfo.py b/generalresearch/models/thl/ipinfo.py index c4ad50c..be9ac14 100644 --- a/generalresearch/models/thl/ipinfo.py +++ b/generalresearch/models/thl/ipinfo.py @@ -2,16 +2,17 @@ from __future__ import annotations import ipaddress from datetime import UTC, datetime -from typing import TYPE_CHECKING, Any, Literal, Self +from typing import Any, Literal, Self from faker import Faker +from grip_client import AccessType from pydantic import ( BaseModel, ConfigDict, Field, PositiveInt, - PrivateAttr, field_validator, + IPvAnyAddress, ) from generalresearch.models.custom_types import ( @@ -19,17 +20,13 @@ from generalresearch.models.custom_types import ( CountryISOLike, IPvAnyAddressStr, ) -from generalresearch.models.thl.maxmind.definitions import UserType - -if TYPE_CHECKING: - from generalresearch.managers.thl.ipinfo import IPGeonameManager fake = Faker() PrefixLength = Literal["/128", "/64", "/32"] -def normalize_ip(ip: IPvAnyAddressStr) -> tuple[str, PrefixLength]: +def normalize_ip(ip: str | IPvAnyAddress) -> tuple[str, PrefixLength]: """ Normalize an IP address for MySQL storage. @@ -41,7 +38,7 @@ def normalize_ip(ip: IPvAnyAddressStr) -> tuple[str, PrefixLength]: """ addr = ipaddress.ip_address(ip) if addr.version == 4: - return ip, "/32" + return addr.exploded, "/32" net64 = ipaddress.IPv6Network((addr, 64), strict=False) return net64.network_address.exploded, "/64" @@ -126,138 +123,34 @@ class IPGeoname(BaseModel): class IPInformation(BaseModel): - ip: IPvAnyAddressStr = Field() - # This doesn't get stored in mysql/redis, b/c we only look up by the normalized ip - lookup_prefix: PrefixLength | None = Field(default=None, exclude=True) + """ + Fields we'll always pull from GRIP's mmdb files at minimum + """ - geoname_id: PositiveInt | None = Field(default=None) + ip: IPvAnyAddressStr = Field() - country_iso: CountryISOLike = Field( + country_iso: CountryISOLike | None = Field( description="The ISO code of the country associated with the IP address.", examples=[fake.country_code().lower()], ) - registered_country_iso: CountryISOLike | None = Field( - default=None, - description="The ISO code of the country where the IP address is registered.", - examples=[fake.country_code().lower()], - ) is_anonymous: bool | None = Field( default=None, description="Indicates whether the IP address is associated with an " "anonymous source (e.g., VPN, proxy).", examples=[False], ) - is_anonymous_vpn: bool | None = Field(default=None) - is_hosting_provider: bool | None = Field(default=None) - is_public_proxy: bool | None = Field(default=None) - is_tor_exit_node: bool | None = Field(default=None) - is_residential_proxy: bool | None = Field(default=None) autonomous_system_number: PositiveInt | None = Field(default=None) autonomous_system_organization: str | None = Field(default=None, max_length=255) - domain: str | None = Field(default=None, max_length=255) - isp: str | None = Field( - default=None, - description="The Internet Service Provider associated with the IP address.", - examples=["Comcast"], - ) - - mobile_country_code: str | None = Field(default=None, max_length=3) - mobile_network_code: str | None = Field(default=None, max_length=3) - - network: str | None = Field(default=None, max_length=56) - organization: str | None = Field(default=None, max_length=255) - - static_ip_score: float | None = Field( - default=None, - description="A score indicating the likelihood that the IP address is static.", - ) - user_type: UserType | None = Field( + access_type: AccessType | None = Field( default=None, description="The type of user associated with the IP address " "(e.g., 'residential', 'business').", - examples=[UserType.RESIDENTIAL], - ) - postal_code: str | None = Field( - default=None, - description="The postal code associated with the IP address.", - examples=[fake.postcode()], - ) - - latitude: float | None = Field( - description="The latitude coordinate of the IP address location.", - default=None, - examples=[float(fake.latitude())], - ) - longitude: float | None = Field( - description="The longitude coordinate of the IP address location.", - default=None, - examples=[float(fake.longitude())], - ) - - accuracy_radius: int | None = Field( - default=None, - description="The approximate radius of accuracy for the latitude " - "and longitude, in kilometers.", - examples=[fake.random_int(min=25, max=250)], - ) - - updated: AwareDatetimeISO = Field( - default_factory=lambda: datetime.now(tz=UTC), + examples=[AccessType.RESIDENTIAL], ) - _geoname: IPGeoname | None = PrivateAttr(default=None) - - @field_validator("country_iso", "registered_country_iso", mode="before") - def make_lower(cls, value: str | None) -> str | None: - if value is not None: - return value.lower() - - return value - - @property - def basic(self) -> bool: - # This could be almost any field, but we're checking here if GRIP - # was run on this record. If not, then most of the optional - # fields will be None - return self.is_anonymous is None - - @property - def geoname(self) -> IPGeoname | None: - return self._geoname or None - - def normalize_ip(self): - normalized_ip, lookup_prefix = normalize_ip(self.ip) - self.ip = normalized_ip - self.lookup_prefix = lookup_prefix - - # --- prefetch_* --- - def prefetch_geoname( - self, - ip_gm: IPGeonameManager, - ) -> None: - if self.geoname_id is None: - raise ValueError("Must provide geoname_id") - - # from generalresearch.managers.thl.ipinfo import IPGeonameManager - # ip_gm = IPGeonameManager(pg_config=pg_config) - - self._geoname = ip_gm.get_by_id(geoname_id=self.geoname_id) - - # --- ORM --- - def model_dump_mysql(self): - d = self.model_dump(mode="json") - d["updated"] = self.updated - return d - - @classmethod - def from_mysql(cls, d: dict[str, Any]) -> Self: - d["updated"] = d["updated"].replace(tzinfo=UTC) - - return cls.model_validate(d) - -class GeoIPInformation(IPInformation, IPGeoname): +class GeoIPInformation(IPInformation): model_config = ConfigDict(extra="ignore") diff --git a/generalresearch/models/thl/maxmind/__init__.py b/generalresearch/models/thl/maxmind/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/generalresearch/models/thl/maxmind/__init__.py +++ /dev/null diff --git a/generalresearch/models/thl/maxmind/definitions.py b/generalresearch/models/thl/maxmind/definitions.py deleted file mode 100644 index 01431c7..0000000 --- a/generalresearch/models/thl/maxmind/definitions.py +++ /dev/null @@ -1,22 +0,0 @@ -from enum import Enum - -from generalresearch.utils.enum import ReprEnumMeta - - -class UserType(Enum, metaclass=ReprEnumMeta): - # https://support.maxmind.com/hc/en-us/articles/4408430082971-IP-Trait-Risk-Data#h_01FN6V8JMQMWZGWNPPAW77ZPY4 - BUSINESS = "business" - CAFE = "cafe" - CELLULAR = "cellular" - COLLEGE = "college" - CDN = "content_delivery_network" - CPN = "consumer_privacy_network" - GOVERNMENT = "government" - HOSTING = "hosting" - LIBRARY = "library" - MILITARY = "military" - RESIDENTIAL = "residential" - ROUTER = "router" - SCHOOL = "school" - SEARCH_ENGINE = "search_engine_spider" - TRAVELER = "traveler" diff --git a/generalresearch/models/thl/user_iphistory.py b/generalresearch/models/thl/user_iphistory.py index a7eadf4..0e09de0 100644 --- a/generalresearch/models/thl/user_iphistory.py +++ b/generalresearch/models/thl/user_iphistory.py @@ -4,7 +4,6 @@ import ipaddress from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Self -from faker import Faker from pydantic import ( BaseModel, ConfigDict, @@ -19,15 +18,12 @@ from generalresearch.models.custom_types import ( IPvAnyAddressStr, ) from generalresearch.models.thl.ipinfo import GeoIPInformation, normalize_ip -from generalresearch.models.thl.user import User +from generalresearch.models.thl.user_ref import UserRef if TYPE_CHECKING: from grip_client.enums import AccessType - from generalresearch.pg_helper import PostgresConfig - from generalresearch.redis_helper import RedisConfig - -fake = Faker() + from generalresearch.managers.thl.ipinfo import GeoIpInfoManager class UserIPRecord(BaseModel): @@ -43,30 +39,12 @@ class UserIPRecord(BaseModel): @property def is_anonymous(self) -> bool: - # default to False even if insights is not looked up - return ( - self.information.is_anonymous - if self.information - and self.information.basic is False - and self.information.is_anonymous is not None - else False - ) - - @property - def user_type(self) -> AccessType | None: - return self.information.user_type if self.information else None + # Default to false if information is not looked up + return (self.information.is_anonymous if self.information else False) or False @property def access_type(self) -> AccessType | None: - return self.information.user_type if self.information else None - - @property - def subdivision_1_iso(self) -> str | None: - return self.information.subdivision_1_iso if self.information else None - - @property - def subdivision_2_iso(self) -> str | None: - return self.information.subdivision_2_iso if self.information else None + return self.information.access_type if self.information else None class IPRecord(BaseModel): @@ -93,27 +71,6 @@ class IPRecord(BaseModel): ) -> bool: return not (ip == self.ip and forwarded_ips == self.forwarded_ips) - # --- prefetch_* --- - def prefetch_ipinfo( - self, - pg_config: PostgresConfig, - redis_config: RedisConfig, - include_forwarded: bool = True, - ) -> None: - from generalresearch.managers.thl.ipinfo import GeoIpInfoManager - - m = GeoIpInfoManager(pg_config=pg_config, redis_config=redis_config) - - if include_forwarded: - ips = {self.ip} - ips.update(set(self.forwarded_ips)) - res = m.get_multi(ips) - self.information = res.get(self.ip) - for x in self.forwarded_ip_records: - x.information = res.get(x.ip) - else: - self.information = m.get(ip_address=self.ip) - # --- ORM --- @classmethod def from_mysql(cls, d: dict) -> Self: @@ -146,7 +103,7 @@ class IPRecord(BaseModel): class UserIPHistory(BaseModel): model_config = ConfigDict(validate_assignment=True) - user_id: PositiveInt = Field() + user: UserRef = Field() # In thl-gprc, we run "audit_ip_history()", and so a user should # get blocked after 100 IP switches or 30 unique IPs @@ -165,9 +122,6 @@ class UserIPHistory(BaseModel): default=None, description="These are any IP addresses that came in " ) - # -- prefetch_ fields - user: User | None = Field(default=None) - @field_validator("ips", mode="after") @classmethod def ips_timestamp(cls, ips): @@ -180,30 +134,11 @@ class UserIPHistory(BaseModel): reverse=True, ) - def prefetch_user( - self, - pg_config: PostgresConfig, - redis_config: RedisConfig, - pg_config_rr: PostgresConfig, - ) -> None: - from generalresearch.managers.thl.user_manager.user_manager import ( - UserManager, - ) - - um = UserManager( - pg_config=pg_config, - pg_config_rr=pg_config_rr, - redis=redis_config.dsn, - ) - self.user = um.get_user(user_id=self.user_id) - - def enrich_ips(self, pg_config: PostgresConfig, redis_config: RedisConfig) -> None: - from generalresearch.managers.thl.ipinfo import GeoIpInfoManager - - m = GeoIpInfoManager(pg_config=pg_config, redis_config=redis_config) - + def enrich_ips(self, geoip_info_manager: GeoIpInfoManager) -> None: + if not self.ips: + return ip_addresses = {x.ip for x in self.ips if x.information is None} - res = m.get_multi(ip_addresses=ip_addresses) + res = geoip_info_manager.get_multi(ip_addresses=ip_addresses) for x in self.ips: if res.get(x.ip): x.information = res[x.ip] diff --git a/generalresearch/models/thl/wallet/cashout_method.py b/generalresearch/models/thl/wallet/cashout_method.py index 1e96818..d9bc34c 100644 --- a/generalresearch/models/thl/wallet/cashout_method.py +++ b/generalresearch/models/thl/wallet/cashout_method.py @@ -214,6 +214,8 @@ class CashMailCashoutMethodData(BaseModel): description="Delivery address where payment should be sent" ) +class CashMailCashoutMethodRequestData(CashMailCashoutMethodData): + pass class PaypalCashoutMethodData(BaseModel): type: Literal[PayoutType.PAYPAL] = Field(default=PayoutType.PAYPAL) @@ -223,6 +225,22 @@ class PaypalCashoutMethodData(BaseModel): examples=["test@example.com"], ) +class PaypalCashoutMethodRequestData(BaseModel): + email: EmailStr = Field( + description="Email address of the paypal user", + examples=["test@example.com"], + ) + interface: Literal["api"] = Field(default="api") + +class TangoCashoutMethodRequestData(BaseModel): + accountIdentifier: str = Field() + customerIdentifier: str = Field() + utid: str = Field(description="tango utid") + amount: USDCent = Field(description="Amount to be paid out") + campaign: Literal["300large"] + sendEmail: bool = Field(default=False) + externalRefID: str = Field(description="External Ref ID") + description: str = Field() class TangoCashoutMethodData(BaseModel): type: Literal[PayoutType.TANGO] = Field(default=PayoutType.TANGO) |
