From b63021c4492a1742b8fa65c39ec648f140e0748b Mon Sep 17 00:00:00 2001 From: stuppie Date: Mon, 21 Sep 2026 18:03:20 -0600 Subject: cashout request email flow and views --- jb-ui/src/JBApp.tsx | 11 +++++-- jb/api/cashout_token.py | 43 ++++++++++++++++++++++++++++ jb/main.py | 2 ++ jb/managers/email_manager.py | 9 ++++++ jb/managers/thl.py | 20 +++++++++---- jb/models/wallet.py | 24 ++++++++++++++++ jb/views/wallet.py | 68 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 168 insertions(+), 9 deletions(-) create mode 100644 jb/api/cashout_token.py create mode 100644 jb/models/wallet.py create mode 100644 jb/views/wallet.py diff --git a/jb-ui/src/JBApp.tsx b/jb-ui/src/JBApp.tsx index 083e52d..67b8dfc 100644 --- a/jb-ui/src/JBApp.tsx +++ b/jb-ui/src/JBApp.tsx @@ -32,6 +32,7 @@ import { addCashoutMethods } from "@/models/cashoutMethodsSlice"; import { addEvent } from "@/models/grlEventsSlice"; import { addStatsData } from "@/models/grlStatsSlice"; +import CashoutConfirmation from "@/pages/CashoutConfirmation"; import MagicLink from "@/pages/MagicLink"; import MagicAMTLink from "@/pages/MagicAMTLink"; import Result from "@/pages/Result"; @@ -110,7 +111,7 @@ function QueryParamProcessor() { const response = res.data as UserLedgerWallets; if (response.displayed_balances) { - setUserDisplayedWalletBalance(response.displayed_balances[0]); + dispatch(setUserDisplayedWalletBalance(response.displayed_balances[0])); } const userWallet = response.wallets?.find( @@ -118,7 +119,7 @@ function QueryParamProcessor() { w.account_type === UserLedgerWalletAccountTypeEnum.UserWallet, ); if (userWallet) { - setUserWallet(userWallet); + dispatch(setUserWallet(userWallet)); } const userAttemptCreditWallet = response.wallets?.find( @@ -127,7 +128,7 @@ function QueryParamProcessor() { UserLedgerWalletAccountTypeEnum.UserAttemptCredit, ); if (userAttemptCreditWallet) { - setUserAttemptCreditWallet(userAttemptCreditWallet); + dispatch(setUserAttemptCreditWallet(userAttemptCreditWallet)); } }); } @@ -276,6 +277,10 @@ function JBApp() { } /> } /> } /> + } + /> } /> } /> diff --git a/jb/api/cashout_token.py b/jb/api/cashout_token.py new file mode 100644 index 0000000..274383c --- /dev/null +++ b/jb/api/cashout_token.py @@ -0,0 +1,43 @@ +import secrets + +from fastapi import HTTPException, status +from generalresearch.redis_helper import RedisConfig + +from jb.api.magic_token import MAGIC_TOKEN_TTL, redis_token_key +from jb.decorators import get_redis_config +from jb.models.wallet import PendingCashout + +CASHOUT_TOKEN_PREFIX = "wallet:cashout:" + + +def create_cashout_token( + cashout: PendingCashout, redis_config: RedisConfig | None = None +) -> str: + """Create a short-lived, single-use token bound to a cashout request.""" + if redis_config is None: + redis_config = get_redis_config() + + token = secrets.token_urlsafe(32) + redis_config.create_redis_client().set( + redis_token_key(token, CASHOUT_TOKEN_PREFIX), + cashout.model_dump_json(), + ex=MAGIC_TOKEN_TTL, + ) + return token + + +def consume_cashout_token( + token: str, redis_config: RedisConfig | None = None +) -> PendingCashout: + if redis_config is None: + redis_config = get_redis_config() + + raw_data = redis_config.create_redis_client().getdel( + redis_token_key(token, CASHOUT_TOKEN_PREFIX) + ) + if raw_data is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired cashout confirmation token", + ) + return PendingCashout.model_validate_json(raw_data) diff --git a/jb/main.py b/jb/main.py index 945b22c..b3b7470 100644 --- a/jb/main.py +++ b/jb/main.py @@ -10,6 +10,7 @@ from jb.config import settings from jb.settings import render_base_html from jb.views.auth import auth_router from jb.views.common import common_router +from jb.views.wallet import wallet_router app = FastAPI( servers=[ @@ -34,6 +35,7 @@ app.add_middleware( app.add_middleware(TrustedHostMiddleware, allowed_hosts=["*"]) app.include_router(router=common_router) app.include_router(router=auth_router) +app.include_router(router=wallet_router) @app.get("/robots.txt") diff --git a/jb/managers/email_manager.py b/jb/managers/email_manager.py index 7298c60..410e9a3 100644 --- a/jb/managers/email_manager.py +++ b/jb/managers/email_manager.py @@ -63,3 +63,12 @@ def send_amt_link_email(email: str, magic_token: str) -> None: ) magic_link = f"{settings.base_url}auth/link-amt/?token={magic_token}" send_login_email_from_url(mautic_url, magic_link) + + +def send_cashout_confirmation_email(email: str, token: str) -> None: + contact_id = get_or_create_contact(email=email) + mautic_url = ( + f"{MAUTIC_BASE_URL}/api/emails/{EMAIL_TEMPLATE_ID}/contact/{contact_id}/send" + ) + magic_link = f"{settings.base_url}wallet/cashout/confirm/?token={token}" + send_login_email_from_url(mautic_url, magic_link) diff --git a/jb/managers/thl.py b/jb/managers/thl.py index 85e0697..f88acad 100644 --- a/jb/managers/thl.py +++ b/jb/managers/thl.py @@ -1,4 +1,5 @@ import requests +from fastapi import HTTPException, status from generalresearch.currency import USDCent from generalresearch.models.thl.definitions import PayoutStatus from generalresearch.models.thl.payout import UserPayoutEvent @@ -9,9 +10,6 @@ from generalresearch.models.thl.wallet.cashout_method import ( ) from jb.config import settings -from jb.models.auth import User - - def get_task_status(tsid: str) -> TaskStatusResponse | None: url = f"{settings.fsb_host}{settings.product_id}/status/{tsid}/" d = requests.get(url).json() @@ -22,20 +20,30 @@ def get_task_status(tsid: str) -> TaskStatusResponse | None: def user_cashout_request( - user: User, amount: USDCent, cashout_method_id: str + product_user_id: str, amount: USDCent, cashout_method_id: str ) -> CashoutRequestInfo: assert isinstance(amount, USDCent) assert USDCent(0) < amount < USDCent(10_00) url = f"{settings.fsb_host}{settings.product_id}/cashout/" body: dict[str, str | int] = { - "bpuid": user.product_user_id, + "bpuid": product_user_id, "amount": int(amount), "cashout_method_id": cashout_method_id, } res = requests.post(url, json=body) - d = res.json() + if res.status_code == status.HTTP_400_BAD_REQUEST: + try: + message = res.json().get("msg") + except (ValueError, AttributeError): + message = None + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=message or "Cashout request failed", + ) + res.raise_for_status() + d = res.json() return CashoutRequestResponse.model_validate(d).cashout diff --git a/jb/models/wallet.py b/jb/models/wallet.py new file mode 100644 index 0000000..4754922 --- /dev/null +++ b/jb/models/wallet.py @@ -0,0 +1,24 @@ +from generalresearch.currency import USDCent +from generalresearch.models.custom_types import UUIDStr +from pydantic import BaseModel, ConfigDict, Field + + +class CashoutRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + amount: USDCent = Field(gt=0, lt=10_00, description="Amount in USD cents") + cashout_method_id: UUIDStr = Field(description="Cashout method ID") + + +class CashoutConfirmation(BaseModel): + model_config = ConfigDict(extra="forbid") + + token: str = Field(min_length=1) + + +class PendingCashout(BaseModel): + model_config = ConfigDict(extra="forbid") + + product_user_id: str + amount: USDCent + cashout_method_id: UUIDStr diff --git a/jb/views/wallet.py b/jb/views/wallet.py new file mode 100644 index 0000000..90e8de8 --- /dev/null +++ b/jb/views/wallet.py @@ -0,0 +1,68 @@ +from typing import Annotated +from urllib.parse import urlencode + +from fastapi import APIRouter, Depends +from fastapi.responses import HTMLResponse +from generalresearch.models.thl.wallet.cashout_method import CashoutRequestInfo + +from jb.api.auth import get_authenticated_user +from jb.api.cashout_token import consume_cashout_token, create_cashout_token +from jb.config import settings +from jb.managers.email_manager import send_cashout_confirmation_email +from jb.managers.thl import user_cashout_request +from jb.models.auth import User +from jb.models.wallet import CashoutConfirmation, CashoutRequest, PendingCashout +from jb.settings import render_base_html + +wallet_router = APIRouter(prefix="/wallet", tags=["Wallet"]) + + +@wallet_router.post("/cashout/request/") +def request_cashout( + body: CashoutRequest, + user: Annotated[User, Depends(get_authenticated_user)], +) -> dict[str, str]: + """Email the authenticated user a link confirming the requested amount.""" + token = create_cashout_token( + PendingCashout( + product_user_id=user.product_user_id, + amount=body.amount, + cashout_method_id=body.cashout_method_id, + ) + ) + query = urlencode({"token": token}) + confirmation_link = f"{settings.base_url}wallet/cashout/confirm/?{query}" + + if settings.debug: + return {"confirmation_link": confirmation_link} + + send_cashout_confirmation_email(email=str(user.email), token=token) + return {"detail": "Confirmation sent. Check your inbox to finish the cashout."} + + +@wallet_router.get( + "/cashout/confirm/", response_class=HTMLResponse, include_in_schema=False +) +def cashout_confirmation_page() -> HTMLResponse: + """Serve the SPA without consuming the token; email prefetches are harmless.""" + return HTMLResponse( + render_base_html(), + headers={ + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + "X-Robots-Tag": "noindex, nofollow", + }, + ) + + +@wallet_router.post("/cashout/confirm/", response_model=CashoutRequestInfo) +def confirm_cashout( + body: CashoutConfirmation, +) -> CashoutRequestInfo: + cashout = consume_cashout_token(body.token) + + return user_cashout_request( + product_user_id=cashout.product_user_id, + amount=cashout.amount, + cashout_method_id=cashout.cashout_method_id, + ) -- cgit v1.2.3 From b1af1375578e41a240a4eedff73209e212a31051 Mon Sep 17 00:00:00 2001 From: stuppie Date: Tue, 22 Sep 2026 15:56:14 -0600 Subject: create_paypal_cashout_method upon user login. fix nginx for docs --- jb-ui/src/pages/CashoutConfirmation.tsx | 41 +++++++++++++++++++++++++++++++++ jb/managers/thl.py | 37 +++++++++++++++++++++++++++++ jb/views/auth.py | 14 +++++++++++ jb/views/wallet.py | 1 + nginx_amt-jb.conf | 10 ++++++++ 5 files changed, 103 insertions(+) create mode 100644 jb-ui/src/pages/CashoutConfirmation.tsx diff --git a/jb-ui/src/pages/CashoutConfirmation.tsx b/jb-ui/src/pages/CashoutConfirmation.tsx new file mode 100644 index 0000000..6964672 --- /dev/null +++ b/jb-ui/src/pages/CashoutConfirmation.tsx @@ -0,0 +1,41 @@ +import { useEffect, useRef, useState } from "react"; + +type ConfirmationState = "working" | "confirmed" | "failed" | "missing"; + +const CashoutConfirmation = function () { + const started = useRef(false); + const [state, setState] = useState("working"); + + useEffect(() => { + if (started.current) return; + started.current = true; + + const params = new URLSearchParams(window.location.search); + const token = params.get("token"); + if (!token) { + setState("missing"); + return; + } + + window.history.replaceState({}, "", window.location.pathname); + + void fetch("/wallet/cashout/confirm/", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }) + .then((response) => { + if (!response.ok) throw new Error("Cashout confirmation failed"); + setState("confirmed"); + }) + .catch(() => setState("failed")); + }, []); + + if (state === "missing") return

This cashout link is missing its token.

; + if (state === "failed") return

This cashout link is invalid or has expired.

; + if (state === "confirmed") return

Your cashout has been confirmed.

; + return

Confirming your cashout…

; +}; + +export default CashoutConfirmation; diff --git a/jb/managers/thl.py b/jb/managers/thl.py index f88acad..59bd2ab 100644 --- a/jb/managers/thl.py +++ b/jb/managers/thl.py @@ -10,6 +10,8 @@ from generalresearch.models.thl.wallet.cashout_method import ( ) from jb.config import settings + + def get_task_status(tsid: str) -> TaskStatusResponse | None: url = f"{settings.fsb_host}{settings.product_id}/status/{tsid}/" d = requests.get(url).json() @@ -61,6 +63,41 @@ def manage_pending_cashout( return UserPayoutEvent.model_validate(d) +def get_paypal_cashout_method_if_exists(product_user_id: str) -> str | None: + """ + Todo: This does not work right now for new users, in that this endpoint expects the user + to have IP history or the request made from the client + (b/c it needs their country to know which tango card are available). + We don't care about that for paypal, but, it expects it anyways. + Once a user can change their paypal email, this needs to change to avoid overwriting it + every time they login. + """ + url = f"{settings.fsb_host}{settings.product_id}/cashout_methods/" + params = {"bpuid": product_user_id} + res = requests.get(url, params=params) + assert res.status_code == status.HTTP_200_OK, res.text + cms = res.json()["cashout_methods"] + res = next(filter(lambda x: x["type"] == "PAYPAL", cms), None) + if res: + return res["id"] + return None + + +def create_paypal_cashout_method(product_user_id: str, email: str) -> dict: + url = f"{settings.fsb_host}{settings.product_id}/cashout_methods/" + body = {"bpuid": product_user_id, "type": "PAYPAL", "email": email} + res = requests.post(url, json=body) + assert res.status_code == status.HTTP_200_OK, res.text + return res.json()["cashout_method"] + + +def create_paypal_cashout_method_if_not_exists( + product_user_id: str, email: str +) -> None: + if not get_paypal_cashout_method_if_exists(product_user_id): + create_paypal_cashout_method(product_user_id, email) + + def get_wallet_balance(amt_worker_id: str) -> USDCent: # This will raise an Exception if wallet balance is negative url = f"{settings.fsb_host}{settings.product_id}/wallet/" diff --git a/jb/views/auth.py b/jb/views/auth.py index cc1224f..36b013f 100644 --- a/jb/views/auth.py +++ b/jb/views/auth.py @@ -26,6 +26,10 @@ from jb.managers.email_manager import ( send_login_email, ) from jb.managers.gr_api import GRApiManager +from jb.managers.thl import ( + create_paypal_cashout_method_if_not_exists, + create_paypal_cashout_method, +) from jb.models.auth import ( AccountLogin, AmtAccountLink, @@ -112,6 +116,14 @@ def exchange_magic_link( def _exchange_magic_link(token: str, response: Response, gr_api: GRApiManager) -> None: user_email = consume_magic_token(token) user = gr_api.ensure_user_exists(User.model_validate({"email": user_email})) + + # create_paypal_cashout_method_if_not_exists( + # product_user_id=user.product_user_id, email=user.email + # ) + create_paypal_cashout_method( + product_user_id=user.product_user_id, email=user.email + ) + response.set_cookie( key=SESSION_COOKIE_NAME, value=create_session(user.product_user_id), @@ -210,6 +222,8 @@ def _exchange_amt_account_link(token: str, response: Response, gr_api: GRApiMana user = User(email=email) user = gr_api.transition_user_from_amt(user=user, amt_worker_id=amt_worker_id) + # create_paypal_cashout_method_if_not_exists(product_user_id=user.product_user_id, email=user.email) + create_paypal_cashout_method(product_user_id=user.product_user_id, email=user.email) # In Mautic, associate the email with the worker ID (AFTER the user has transitioned) get_or_create_contact(email=email, amt_worker_id=amt_worker_id) diff --git a/jb/views/wallet.py b/jb/views/wallet.py index 90e8de8..c281698 100644 --- a/jb/views/wallet.py +++ b/jb/views/wallet.py @@ -58,6 +58,7 @@ def cashout_confirmation_page() -> HTMLResponse: @wallet_router.post("/cashout/confirm/", response_model=CashoutRequestInfo) def confirm_cashout( body: CashoutConfirmation, + user: Annotated[User, Depends(get_authenticated_user)], ) -> CashoutRequestInfo: cashout = consume_cashout_token(body.token) diff --git a/nginx_amt-jb.conf b/nginx_amt-jb.conf index 6d6947c..0b0f385 100644 --- a/nginx_amt-jb.conf +++ b/nginx_amt-jb.conf @@ -30,6 +30,16 @@ server { return 200 '{"status":"ok"}'; } + location = /docs/ { + include nginx_amt-jb_proxy_pass.conf; + proxy_pass http://uvicorn/docs; + } + + location = /redoc/ { + include nginx_amt-jb_proxy_pass.conf; + proxy_pass http://uvicorn/redoc; + } + location / { include nginx_amt-jb_proxy_pass.conf; proxy_pass http://uvicorn; -- cgit v1.2.3 From 064a55c754e02da54b13c47028b233b265327518 Mon Sep 17 00:00:00 2001 From: stuppie Date: Wed, 23 Sep 2026 13:42:03 -0600 Subject: upon login: create_paypal_cashout_method_if_not_exists using user's ip. Upon cashout request: send_cashout_confirmation_email using cashout method info looked up --- jb/managers/email_manager.py | 43 +++++++++++++++++++++++++++++-------------- jb/managers/thl.py | 29 +++++++++++++++++++---------- jb/views/auth.py | 44 ++++++++++++++++++++++++++++---------------- jb/views/utils.py | 20 +++++++------------- jb/views/wallet.py | 10 +++++++--- 5 files changed, 90 insertions(+), 56 deletions(-) diff --git a/jb/managers/email_manager.py b/jb/managers/email_manager.py index 410e9a3..8006844 100644 --- a/jb/managers/email_manager.py +++ b/jb/managers/email_manager.py @@ -1,10 +1,14 @@ import requests from generalresearch.config import is_debug +from generalresearch.currency import USDCent +from generalresearch.models.thl.wallet.cashout_method import CashoutMethodOut from jb.config import settings MAUTIC_BASE_URL = "https://mail.jamesbillings67.com" -EMAIL_TEMPLATE_ID = 1 +LOGIN_EMAIL_TEMPLATE_ID = 1 +# Todo: We need a new template for the cashout confirmation +CASHOUT_EMAIL_TEMPLATE_ID = 3 assert settings.mautic_api_key auth_headers = {"Authorization": f"Basic {settings.mautic_api_key.get_secret_value()}"} @@ -26,11 +30,14 @@ def send_login_email_from_url(mautic_url: str, magic_link: str) -> None: if is_debug(): print("MAGIC_LINK: ", magic_link) return - email_tokens = { "magic_link": magic_link, } - body = {"tokens": email_tokens} + send_email_with_tokens(email_tokens, mautic_url) + + +def send_email_with_tokens(tokens: dict[str, str], mautic_url: str) -> None: + body = {"tokens": tokens} response = requests.post(url=mautic_url, json=body, headers=auth_headers) try: @@ -47,9 +54,7 @@ def send_login_email_from_url(mautic_url: str, magic_link: str) -> None: def send_login_email(email: str, magic_token: str) -> None: contact_id = get_or_create_contact(email=email) - mautic_url = ( - f"{MAUTIC_BASE_URL}/api/emails/{EMAIL_TEMPLATE_ID}/contact/{contact_id}/send" - ) + mautic_url = f"{MAUTIC_BASE_URL}/api/emails/{LOGIN_EMAIL_TEMPLATE_ID}/contact/{contact_id}/send" magic_link = f"{settings.base_url}auth/magic-link/?token={magic_token}" send_login_email_from_url(mautic_url, magic_link) @@ -58,17 +63,27 @@ def send_amt_link_email(email: str, magic_token: str) -> None: # Don't actually associate the email with the worker ID # until they click the link contact_id = get_or_create_contact(email=email) - mautic_url = ( - f"{MAUTIC_BASE_URL}/api/emails/{EMAIL_TEMPLATE_ID}/contact/{contact_id}/send" - ) + mautic_url = f"{MAUTIC_BASE_URL}/api/emails/{LOGIN_EMAIL_TEMPLATE_ID}/contact/{contact_id}/send" magic_link = f"{settings.base_url}auth/link-amt/?token={magic_token}" send_login_email_from_url(mautic_url, magic_link) -def send_cashout_confirmation_email(email: str, token: str) -> None: +def send_cashout_confirmation_email( + email: str, token: str, cashout_method: CashoutMethodOut, amount: USDCent +) -> None: contact_id = get_or_create_contact(email=email) - mautic_url = ( - f"{MAUTIC_BASE_URL}/api/emails/{EMAIL_TEMPLATE_ID}/contact/{contact_id}/send" - ) + mautic_url = f"{MAUTIC_BASE_URL}/api/emails/{CASHOUT_EMAIL_TEMPLATE_ID}/contact/{contact_id}/send" magic_link = f"{settings.base_url}wallet/cashout/confirm/?token={token}" - send_login_email_from_url(mautic_url, magic_link) + if is_debug(): + print("MAGIC_LINK: ", magic_link) + return + email_tokens = { + "magic_link": magic_link, + "cashout_method_name": cashout_method.name, + "cashout_method_description": cashout_method.description, + "cashout_method_type": cashout_method.type.value, + "amount": amount.to_usd_str(), + } + if cashout_method.image_url: + email_tokens["image_url"] = cashout_method.image_url + send_email_with_tokens(email_tokens, mautic_url) diff --git a/jb/managers/thl.py b/jb/managers/thl.py index 59bd2ab..4d6b23a 100644 --- a/jb/managers/thl.py +++ b/jb/managers/thl.py @@ -5,6 +5,7 @@ from generalresearch.models.thl.definitions import PayoutStatus from generalresearch.models.thl.payout import UserPayoutEvent from generalresearch.models.thl.task_status import TaskStatusResponse from generalresearch.models.thl.wallet.cashout_method import ( + CashoutMethodOut, CashoutRequestInfo, CashoutRequestResponse, ) @@ -63,17 +64,16 @@ def manage_pending_cashout( return UserPayoutEvent.model_validate(d) -def get_paypal_cashout_method_if_exists(product_user_id: str) -> str | None: +def get_paypal_cashout_method_if_exists( + product_user_id: str, client_ip: str +) -> str | None: """ - Todo: This does not work right now for new users, in that this endpoint expects the user - to have IP history or the request made from the client - (b/c it needs their country to know which tango card are available). - We don't care about that for paypal, but, it expects it anyways. - Once a user can change their paypal email, this needs to change to avoid overwriting it - every time they login. + Make sure a paypal cashout method exists for this user. Use their login email. + We check if it exists first, so that once a user can change their paypal email, + we do not overwrite it. """ url = f"{settings.fsb_host}{settings.product_id}/cashout_methods/" - params = {"bpuid": product_user_id} + params = {"bpuid": product_user_id, "ip": client_ip} res = requests.get(url, params=params) assert res.status_code == status.HTTP_200_OK, res.text cms = res.json()["cashout_methods"] @@ -92,12 +92,21 @@ def create_paypal_cashout_method(product_user_id: str, email: str) -> dict: def create_paypal_cashout_method_if_not_exists( - product_user_id: str, email: str + product_user_id: str, email: str, client_ip: str ) -> None: - if not get_paypal_cashout_method_if_exists(product_user_id): + if not get_paypal_cashout_method_if_exists(product_user_id, client_ip=client_ip): create_paypal_cashout_method(product_user_id, email) +def get_cashout_method(cashout_method_id: str) -> CashoutMethodOut: + url = ( + f"{settings.fsb_host}{settings.product_id}/cashout_methods/{cashout_method_id}/" + ) + res = requests.get(url) + assert res.status_code == status.HTTP_200_OK, res.text + return CashoutMethodOut.model_validate(res.json()["cashout_method"]) + + def get_wallet_balance(amt_worker_id: str) -> USDCent: # This will raise an Exception if wallet balance is negative url = f"{settings.fsb_host}{settings.product_id}/wallet/" diff --git a/jb/views/auth.py b/jb/views/auth.py index 36b013f..aa3cf03 100644 --- a/jb/views/auth.py +++ b/jb/views/auth.py @@ -2,7 +2,7 @@ import secrets from typing import Annotated from urllib.parse import urlencode -from fastapi import APIRouter, Depends, Header, HTTPException, Response, status +from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response, status from fastapi.responses import HTMLResponse, RedirectResponse from jb.api.auth import ( @@ -26,10 +26,7 @@ from jb.managers.email_manager import ( send_login_email, ) from jb.managers.gr_api import GRApiManager -from jb.managers.thl import ( - create_paypal_cashout_method_if_not_exists, - create_paypal_cashout_method, -) +from jb.managers.thl import create_paypal_cashout_method_if_not_exists from jb.models.auth import ( AccountLogin, AmtAccountLink, @@ -37,6 +34,7 @@ from jb.models.auth import ( User, ) from jb.settings import render_base_html +from jb.views.utils import get_client_ip auth_router = APIRouter(prefix="/auth", tags=["Auth"]) @@ -80,6 +78,7 @@ def request_magic_link(body: AccountLogin) -> dict[str, str]: @auth_router.get("/magic-link/", response_class=HTMLResponse, include_in_schema=False) def magic_link_landing_page( + request: Request, gr_api: Annotated[GRApiManager, Depends(get_gr_api_manager)], token: str | None = None, ) -> Response: @@ -91,7 +90,8 @@ def magic_link_landing_page( detail="token is required", ) response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) - _exchange_magic_link(token, response, gr_api) + client_ip = get_client_ip(request) + _exchange_magic_link(token, response, gr_api, client_ip=client_ip) return response return HTMLResponse( render_base_html(), @@ -105,23 +105,27 @@ def magic_link_landing_page( @auth_router.post("/magic-link/exchange/", status_code=status.HTTP_204_NO_CONTENT) def exchange_magic_link( + request: Request, body: MagicLinkExchangeRequest, response: Response, gr_api: Annotated[GRApiManager, Depends(get_gr_api_manager)], ) -> None: """Exchange a magic link only after its landing page makes an explicit POST.""" - _exchange_magic_link(body.token, response, gr_api) + client_ip = get_client_ip(request) + _exchange_magic_link(body.token, response, gr_api, client_ip=client_ip) -def _exchange_magic_link(token: str, response: Response, gr_api: GRApiManager) -> None: +def _exchange_magic_link( + token: str, response: Response, gr_api: GRApiManager, client_ip: str +) -> None: user_email = consume_magic_token(token) user = gr_api.ensure_user_exists(User.model_validate({"email": user_email})) # create_paypal_cashout_method_if_not_exists( # product_user_id=user.product_user_id, email=user.email # ) - create_paypal_cashout_method( - product_user_id=user.product_user_id, email=user.email + create_paypal_cashout_method_if_not_exists( + product_user_id=user.product_user_id, email=user.email, client_ip=client_ip ) response.set_cookie( @@ -173,6 +177,7 @@ def invite_amt_account_link( @auth_router.get("/debug/", response_class=HTMLResponse, include_in_schema=False) def link_amt_account_landing_page( + request: Request, gr_api: Annotated[GRApiManager, Depends(get_gr_api_manager)], token: str | None = None, ) -> HTMLResponse: @@ -187,9 +192,11 @@ def link_amt_account_landing_page( status_code=status.HTTP_400_BAD_REQUEST, detail="token is required", ) - + client_ip = get_client_ip(request) _response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) - _exchange_amt_account_link(token=token, response=_response, gr_api=gr_api) + _exchange_amt_account_link( + token=token, response=_response, gr_api=gr_api, client_ip=client_ip + ) return HTMLResponse( render_base_html(), @@ -203,27 +210,32 @@ def link_amt_account_landing_page( @auth_router.post("/link-amt/exchange/", status_code=status.HTTP_204_NO_CONTENT) def exchange_amt_account_link( + request: Request, body: MagicLinkExchangeRequest, response: Response, gr_api: Annotated[GRApiManager, Depends(get_gr_api_manager)], ) -> None: """Validate the email link, then transition the bound AMT account.""" + client_ip = get_client_ip(request) try: - _exchange_amt_account_link(body.token, response, gr_api) + _exchange_amt_account_link(body.token, response, gr_api, client_ip=client_ip) except ValueError as e: LOG.error(f"Failed to exchange AMT account link: {e}") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -def _exchange_amt_account_link(token: str, response: Response, gr_api: GRApiManager): +def _exchange_amt_account_link( + token: str, response: Response, gr_api: GRApiManager, client_ip: str +): token_data = consume_amt_account_link_token(token) email = token_data.email amt_worker_id = token_data.amt_worker_id user = User(email=email) user = gr_api.transition_user_from_amt(user=user, amt_worker_id=amt_worker_id) - # create_paypal_cashout_method_if_not_exists(product_user_id=user.product_user_id, email=user.email) - create_paypal_cashout_method(product_user_id=user.product_user_id, email=user.email) + create_paypal_cashout_method_if_not_exists( + product_user_id=user.product_user_id, email=user.email, client_ip=client_ip + ) # In Mautic, associate the email with the worker ID (AFTER the user has transitioned) get_or_create_contact(email=email, amt_worker_id=amt_worker_id) diff --git a/jb/views/utils.py b/jb/views/utils.py index 0d08e9b..a133263 100644 --- a/jb/views/utils.py +++ b/jb/views/utils.py @@ -2,17 +2,11 @@ from fastapi import Request def get_client_ip(request: Request) -> str: - """ - Using a testclient, the ip returned is 'testclient'. If so, instead, grab - the ip from the headers - """ - ip = request.headers.get("X-Forwarded-For") - if not ip: - ip = request.client.host # type: ignore - elif ip == "testclient" or ip.startswith("10."): - forwarded = request.headers.get("X-Forwarded-For") - ip = ( - forwarded.split(",")[0].strip() if forwarded else request.client.host # type: ignore - ) + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",", 1)[0].strip() - return ip + if request.client is None: + raise ValueError("Client IP is unavailable") + + return request.client.host diff --git a/jb/views/wallet.py b/jb/views/wallet.py index c281698..9fda533 100644 --- a/jb/views/wallet.py +++ b/jb/views/wallet.py @@ -9,7 +9,7 @@ from jb.api.auth import get_authenticated_user from jb.api.cashout_token import consume_cashout_token, create_cashout_token from jb.config import settings from jb.managers.email_manager import send_cashout_confirmation_email -from jb.managers.thl import user_cashout_request +from jb.managers.thl import get_cashout_method, user_cashout_request from jb.models.auth import User from jb.models.wallet import CashoutConfirmation, CashoutRequest, PendingCashout from jb.settings import render_base_html @@ -33,10 +33,14 @@ def request_cashout( query = urlencode({"token": token}) confirmation_link = f"{settings.base_url}wallet/cashout/confirm/?{query}" + cm = get_cashout_method(cashout_method_id=body.cashout_method_id) + if settings.debug: - return {"confirmation_link": confirmation_link} + return {"confirmation_link": confirmation_link, "cashout_method": cm.id} - send_cashout_confirmation_email(email=str(user.email), token=token) + send_cashout_confirmation_email( + email=str(user.email), token=token, cashout_method=cm, amount=body.amount + ) return {"detail": "Confirmation sent. Check your inbox to finish the cashout."} -- cgit v1.2.3 From 785adc51b2cc2b4d3e845a454f38b2a5197cc7f0 Mon Sep 17 00:00:00 2001 From: stuppie Date: Wed, 23 Sep 2026 15:07:47 -0600 Subject: working on cashout postback, to be hit by thl upon cashout status change, which will send the user an email with the info --- jb/managers/email_manager.py | 29 +++++++++++++++++-- jb/managers/thl.py | 7 +++++ jb/models/wallet.py | 6 ++++ jb/views/wallet.py | 67 +++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 102 insertions(+), 7 deletions(-) diff --git a/jb/managers/email_manager.py b/jb/managers/email_manager.py index 8006844..08ac0cf 100644 --- a/jb/managers/email_manager.py +++ b/jb/managers/email_manager.py @@ -1,14 +1,19 @@ import requests from generalresearch.config import is_debug from generalresearch.currency import USDCent -from generalresearch.models.thl.wallet.cashout_method import CashoutMethodOut +from generalresearch.models.thl.wallet.cashout_method import ( + CashoutMethodOut, + CashoutRequestInfo, +) from jb.config import settings MAUTIC_BASE_URL = "https://mail.jamesbillings67.com" LOGIN_EMAIL_TEMPLATE_ID = 1 # Todo: We need a new template for the cashout confirmation -CASHOUT_EMAIL_TEMPLATE_ID = 3 +CASHOUT_REQUEST_EMAIL_TEMPLATE_ID = 3 +# Todo: We need a new template for the cashout status / sent +CASHOUT_STATUS_EMAIL_TEMPLATE_ID = 4 assert settings.mautic_api_key auth_headers = {"Authorization": f"Basic {settings.mautic_api_key.get_secret_value()}"} @@ -72,7 +77,7 @@ def send_cashout_confirmation_email( email: str, token: str, cashout_method: CashoutMethodOut, amount: USDCent ) -> None: contact_id = get_or_create_contact(email=email) - mautic_url = f"{MAUTIC_BASE_URL}/api/emails/{CASHOUT_EMAIL_TEMPLATE_ID}/contact/{contact_id}/send" + mautic_url = f"{MAUTIC_BASE_URL}/api/emails/{CASHOUT_REQUEST_EMAIL_TEMPLATE_ID}/contact/{contact_id}/send" magic_link = f"{settings.base_url}wallet/cashout/confirm/?token={token}" if is_debug(): print("MAGIC_LINK: ", magic_link) @@ -87,3 +92,21 @@ def send_cashout_confirmation_email( if cashout_method.image_url: email_tokens["image_url"] = cashout_method.image_url send_email_with_tokens(email_tokens, mautic_url) + + +def send_cashout_status_email(email: str, cashout: CashoutRequestInfo) -> None: + assert cashout.status is not None + email_tokens = { + "cashout_id": str(cashout.id), + "cashout_status": cashout.status.value, + } + # todo: (if tango) format credentials and redemption instructions + # "credentials": "", + # "redemption_instructions": "", + + if is_debug(): + print("CASHOUT_STATUS_EMAIL: ", email_tokens) + return + contact_id = get_or_create_contact(email=email) + mautic_url = f"{MAUTIC_BASE_URL}/api/emails/{CASHOUT_STATUS_EMAIL_TEMPLATE_ID}/contact/{contact_id}/send" + send_email_with_tokens(email_tokens, mautic_url) diff --git a/jb/managers/thl.py b/jb/managers/thl.py index 4d6b23a..191a56b 100644 --- a/jb/managers/thl.py +++ b/jb/managers/thl.py @@ -121,3 +121,10 @@ def get_wallet_balance_if_non_negative(amt_worker_id: str) -> USDCent | None: if amt >= 0: return USDCent(amt) return None + + +def get_cashout_detail(cashout_id: str) -> CashoutRequestInfo: + url = f"{settings.fsb_host}{settings.product_id}/cashout/{cashout_id}/" + res = requests.get(url) + assert res.status_code == status.HTTP_200_OK, res.text + return CashoutRequestInfo.model_validate(res.json()["cashout"]) diff --git a/jb/models/wallet.py b/jb/models/wallet.py index 4754922..226e71f 100644 --- a/jb/models/wallet.py +++ b/jb/models/wallet.py @@ -16,6 +16,12 @@ class CashoutConfirmation(BaseModel): token: str = Field(min_length=1) +class CashoutPostback(BaseModel): + model_config = ConfigDict(extra="forbid") + + cashout_id: UUIDStr + + class PendingCashout(BaseModel): model_config = ConfigDict(extra="forbid") diff --git a/jb/views/wallet.py b/jb/views/wallet.py index 9fda533..681cc21 100644 --- a/jb/views/wallet.py +++ b/jb/views/wallet.py @@ -1,17 +1,35 @@ +from datetime import timedelta from typing import Annotated from urllib.parse import urlencode -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status from fastapi.responses import HTMLResponse +from generalresearch.models.thl.definitions import PayoutStatus from generalresearch.models.thl.wallet.cashout_method import CashoutRequestInfo +from generalresearch.redis_helper import RedisConfig from jb.api.auth import get_authenticated_user from jb.api.cashout_token import consume_cashout_token, create_cashout_token from jb.config import settings -from jb.managers.email_manager import send_cashout_confirmation_email -from jb.managers.thl import get_cashout_method, user_cashout_request +from jb.decorators import get_redis_config +from jb.dependencies import get_gr_api_manager +from jb.managers.email_manager import ( + send_cashout_confirmation_email, + send_cashout_status_email, +) +from jb.managers.gr_api import GRApiManager +from jb.managers.thl import ( + get_cashout_detail, + get_cashout_method, + user_cashout_request, +) from jb.models.auth import User -from jb.models.wallet import CashoutConfirmation, CashoutRequest, PendingCashout +from jb.models.wallet import ( + CashoutConfirmation, + CashoutPostback, + CashoutRequest, + PendingCashout, +) from jb.settings import render_base_html wallet_router = APIRouter(prefix="/wallet", tags=["Wallet"]) @@ -71,3 +89,44 @@ def confirm_cashout( amount=cashout.amount, cashout_method_id=cashout.cashout_method_id, ) + + +@wallet_router.post("/cashout/postback/", status_code=status.HTTP_204_NO_CONTENT) +def cashout_postback( + body: CashoutPostback, + gr_api: Annotated[GRApiManager, Depends(get_gr_api_manager)], + redis_config: Annotated[RedisConfig, Depends(get_redis_config)], +) -> None: + # Treat the posted ID only as a lookup key; THL supplies the trusted details. + try: + cashout = get_cashout_detail(body.cashout_id) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Cashout not found: {e}", + ) + if cashout.product_id != settings.product_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cashout not found", + ) + if cashout.status != PayoutStatus.COMPLETE: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="Cashout is not complete", + ) + # In case THL retries, send at most one email for each + dedupe_key = f"wallet:cashout-email-sent:{cashout.id}:{cashout.status.value}" + redis_client = redis_config.create_redis_client() + claimed = redis_client.set(dedupe_key, "sending", nx=True, ex=timedelta(minutes=5)) + if not claimed: + return + + try: + user = gr_api.get_user(product_user_id=cashout.product_user_id) + send_cashout_status_email(email=str(user.email), cashout=cashout) + redis_client.set(dedupe_key, "sent", ex=timedelta(minutes=30)) + except Exception: + # Allow a later retry when user lookup or email delivery fails. + redis_client.delete(dedupe_key) + raise -- cgit v1.2.3 From 6aaf8d26bb3e2cc3a004fcb1c94ddc85a1fc6a45 Mon Sep 17 00:00:00 2001 From: stuppie Date: Wed, 23 Sep 2026 19:34:12 -0600 Subject: move create_paypal_cashout_method_if_not_exists into fastapi background task. Change CashoutRequest max -> --- jb/managers/thl.py | 2 +- jb/models/wallet.py | 2 +- jb/views/auth.py | 88 ++++++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 76 insertions(+), 16 deletions(-) diff --git a/jb/managers/thl.py b/jb/managers/thl.py index 191a56b..96f624e 100644 --- a/jb/managers/thl.py +++ b/jb/managers/thl.py @@ -26,7 +26,7 @@ def user_cashout_request( product_user_id: str, amount: USDCent, cashout_method_id: str ) -> CashoutRequestInfo: assert isinstance(amount, USDCent) - assert USDCent(0) < amount < USDCent(10_00) + assert USDCent(0) < amount <= USDCent(100_00) url = f"{settings.fsb_host}{settings.product_id}/cashout/" body: dict[str, str | int] = { diff --git a/jb/models/wallet.py b/jb/models/wallet.py index 226e71f..3d4d10b 100644 --- a/jb/models/wallet.py +++ b/jb/models/wallet.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field class CashoutRequest(BaseModel): model_config = ConfigDict(extra="forbid") - amount: USDCent = Field(gt=0, lt=10_00, description="Amount in USD cents") + amount: USDCent = Field(gt=0, le=100_00, description="Amount in USD cents") cashout_method_id: UUIDStr = Field(description="Cashout method ID") diff --git a/jb/views/auth.py b/jb/views/auth.py index aa3cf03..ba1a6f8 100644 --- a/jb/views/auth.py +++ b/jb/views/auth.py @@ -2,7 +2,16 @@ import secrets from typing import Annotated from urllib.parse import urlencode -from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response, status +from fastapi import ( + APIRouter, + BackgroundTasks, + Depends, + Header, + HTTPException, + Request, + Response, + status, +) from fastapi.responses import HTMLResponse, RedirectResponse from jb.api.auth import ( @@ -39,6 +48,19 @@ from jb.views.utils import get_client_ip auth_router = APIRouter(prefix="/auth", tags=["Auth"]) +def try_create_paypal_cashout_method_if_not_exists( + product_user_id: str, email: str, client_ip: str +) -> None: + try: + create_paypal_cashout_method_if_not_exists( + product_user_id=product_user_id, + email=email, + client_ip=client_ip, + ) + except Exception: + LOG.exception("Failed to create PayPal cashout method for %s", product_user_id) + + def authenticate_invite_amt_account_link( authorization: Annotated[str | None, Header()] = None, ) -> None: @@ -79,6 +101,7 @@ def request_magic_link(body: AccountLogin) -> dict[str, str]: @auth_router.get("/magic-link/", response_class=HTMLResponse, include_in_schema=False) def magic_link_landing_page( request: Request, + background_tasks: BackgroundTasks, gr_api: Annotated[GRApiManager, Depends(get_gr_api_manager)], token: str | None = None, ) -> Response: @@ -91,7 +114,13 @@ def magic_link_landing_page( ) response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) client_ip = get_client_ip(request) - _exchange_magic_link(token, response, gr_api, client_ip=client_ip) + _exchange_magic_link( + token, + response, + gr_api, + client_ip=client_ip, + background_tasks=background_tasks, + ) return response return HTMLResponse( render_base_html(), @@ -106,26 +135,38 @@ def magic_link_landing_page( @auth_router.post("/magic-link/exchange/", status_code=status.HTTP_204_NO_CONTENT) def exchange_magic_link( request: Request, + background_tasks: BackgroundTasks, body: MagicLinkExchangeRequest, response: Response, gr_api: Annotated[GRApiManager, Depends(get_gr_api_manager)], ) -> None: """Exchange a magic link only after its landing page makes an explicit POST.""" client_ip = get_client_ip(request) - _exchange_magic_link(body.token, response, gr_api, client_ip=client_ip) + _exchange_magic_link( + body.token, + response, + gr_api, + client_ip=client_ip, + background_tasks=background_tasks, + ) def _exchange_magic_link( - token: str, response: Response, gr_api: GRApiManager, client_ip: str + token: str, + response: Response, + gr_api: GRApiManager, + client_ip: str, + background_tasks: BackgroundTasks, ) -> None: user_email = consume_magic_token(token) user = gr_api.ensure_user_exists(User.model_validate({"email": user_email})) - # create_paypal_cashout_method_if_not_exists( - # product_user_id=user.product_user_id, email=user.email - # ) - create_paypal_cashout_method_if_not_exists( - product_user_id=user.product_user_id, email=user.email, client_ip=client_ip + # Cashout setup is not required for login and should not delay the response. + background_tasks.add_task( + try_create_paypal_cashout_method_if_not_exists, + product_user_id=user.product_user_id, + email=str(user.email), + client_ip=client_ip, ) response.set_cookie( @@ -178,6 +219,7 @@ def invite_amt_account_link( @auth_router.get("/debug/", response_class=HTMLResponse, include_in_schema=False) def link_amt_account_landing_page( request: Request, + background_tasks: BackgroundTasks, gr_api: Annotated[GRApiManager, Depends(get_gr_api_manager)], token: str | None = None, ) -> HTMLResponse: @@ -195,7 +237,11 @@ def link_amt_account_landing_page( client_ip = get_client_ip(request) _response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) _exchange_amt_account_link( - token=token, response=_response, gr_api=gr_api, client_ip=client_ip + token=token, + response=_response, + gr_api=gr_api, + client_ip=client_ip, + background_tasks=background_tasks, ) return HTMLResponse( @@ -211,6 +257,7 @@ def link_amt_account_landing_page( @auth_router.post("/link-amt/exchange/", status_code=status.HTTP_204_NO_CONTENT) def exchange_amt_account_link( request: Request, + background_tasks: BackgroundTasks, body: MagicLinkExchangeRequest, response: Response, gr_api: Annotated[GRApiManager, Depends(get_gr_api_manager)], @@ -218,14 +265,24 @@ def exchange_amt_account_link( """Validate the email link, then transition the bound AMT account.""" client_ip = get_client_ip(request) try: - _exchange_amt_account_link(body.token, response, gr_api, client_ip=client_ip) + _exchange_amt_account_link( + body.token, + response, + gr_api, + client_ip=client_ip, + background_tasks=background_tasks, + ) except ValueError as e: LOG.error(f"Failed to exchange AMT account link: {e}") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) def _exchange_amt_account_link( - token: str, response: Response, gr_api: GRApiManager, client_ip: str + token: str, + response: Response, + gr_api: GRApiManager, + client_ip: str, + background_tasks: BackgroundTasks, ): token_data = consume_amt_account_link_token(token) email = token_data.email @@ -233,8 +290,11 @@ def _exchange_amt_account_link( user = User(email=email) user = gr_api.transition_user_from_amt(user=user, amt_worker_id=amt_worker_id) - create_paypal_cashout_method_if_not_exists( - product_user_id=user.product_user_id, email=user.email, client_ip=client_ip + background_tasks.add_task( + try_create_paypal_cashout_method_if_not_exists, + product_user_id=user.product_user_id, + email=str(user.email), + client_ip=client_ip, ) # In Mautic, associate the email with the worker ID (AFTER the user has transitioned) -- cgit v1.2.3 From be986ab84f7b12c211f7675071d4deae1bf2bd03 Mon Sep 17 00:00:00 2001 From: stuppie Date: Thu, 24 Sep 2026 12:24:11 -0600 Subject: update generalresearch --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index adfe6e9..b800a10 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -git+ssh://code.g-r-l.com:6611/generalresearch@v3.4.7 +git+ssh://code.g-r-l.com:6611/generalresearch@v3.6.0 aiohappyeyeballs==2.6.1 aiohttp==3.13.0 aiosignal==1.4.0 -- cgit v1.2.3