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