aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--jb-ui/src/JBApp.tsx11
-rw-r--r--jb-ui/src/pages/CashoutConfirmation.tsx41
-rw-r--r--jb/api/cashout_token.py43
-rw-r--r--jb/main.py2
-rw-r--r--jb/managers/email_manager.py65
-rw-r--r--jb/managers/thl.py71
-rw-r--r--jb/models/wallet.py30
-rw-r--r--jb/views/auth.py102
-rw-r--r--jb/views/utils.py20
-rw-r--r--jb/views/wallet.py132
-rw-r--r--nginx_amt-jb.conf10
-rw-r--r--requirements.txt2
12 files changed, 490 insertions, 39 deletions
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() {
<Route path="/" element={<Work />} />
<Route path="/auth/magic-link/" element={<MagicLink />} />
<Route path="/auth/link-amt/" element={<MagicAMTLink />} />
+ <Route
+ path="/wallet/cashout/confirm/"
+ element={<CashoutConfirmation />}
+ />
<Route path="/preview/" element={<Transfer />} />
<Route path="/work/" element={<Transfer />} />
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<ConfirmationState>("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 <p>This cashout link is missing its token.</p>;
+ if (state === "failed") return <p>This cashout link is invalid or has expired.</p>;
+ if (state === "confirmed") return <p>Your cashout has been confirmed.</p>;
+ return <p>Confirming your cashout…</p>;
+};
+
+export default CashoutConfirmation;
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..08ac0cf 100644
--- a/jb/managers/email_manager.py
+++ b/jb/managers/email_manager.py
@@ -1,10 +1,19 @@
import requests
from generalresearch.config import is_debug
+from generalresearch.currency import USDCent
+from generalresearch.models.thl.wallet.cashout_method import (
+ CashoutMethodOut,
+ CashoutRequestInfo,
+)
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_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()}"}
@@ -26,11 +35,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 +59,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,8 +68,45 @@ 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, cashout_method: CashoutMethodOut, amount: USDCent
+) -> None:
+ contact_id = get_or_create_contact(email=email)
+ 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)
+ 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)
+
+
+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 85e0697..96f624e 100644
--- a/jb/managers/thl.py
+++ b/jb/managers/thl.py
@@ -1,15 +1,16 @@
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
from generalresearch.models.thl.task_status import TaskStatusResponse
from generalresearch.models.thl.wallet.cashout_method import (
+ CashoutMethodOut,
CashoutRequestInfo,
CashoutRequestResponse,
)
from jb.config import settings
-from jb.models.auth import User
def get_task_status(tsid: str) -> TaskStatusResponse | None:
@@ -22,20 +23,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)
+ assert USDCent(0) < amount <= USDCent(100_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
@@ -53,6 +64,49 @@ def manage_pending_cashout(
return UserPayoutEvent.model_validate(d)
+def get_paypal_cashout_method_if_exists(
+ product_user_id: str, client_ip: str
+) -> str | None:
+ """
+ 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, "ip": client_ip}
+ 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, client_ip: str
+) -> None:
+ 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/"
@@ -67,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
new file mode 100644
index 0000000..3d4d10b
--- /dev/null
+++ b/jb/models/wallet.py
@@ -0,0 +1,30 @@
+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, le=100_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 CashoutPostback(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ cashout_id: UUIDStr
+
+
+class PendingCashout(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ product_user_id: str
+ amount: USDCent
+ cashout_method_id: UUIDStr
diff --git a/jb/views/auth.py b/jb/views/auth.py
index cc1224f..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, Response, status
+from fastapi import (
+ APIRouter,
+ BackgroundTasks,
+ Depends,
+ Header,
+ HTTPException,
+ Request,
+ Response,
+ status,
+)
from fastapi.responses import HTMLResponse, RedirectResponse
from jb.api.auth import (
@@ -26,6 +35,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
from jb.models.auth import (
AccountLogin,
AmtAccountLink,
@@ -33,10 +43,24 @@ 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"])
+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:
@@ -76,6 +100,8 @@ 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:
@@ -87,7 +113,14 @@ 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,
+ background_tasks=background_tasks,
+ )
return response
return HTMLResponse(
render_base_html(),
@@ -101,17 +134,41 @@ 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."""
- _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,
+ background_tasks=background_tasks,
+ )
-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,
+ background_tasks: BackgroundTasks,
+) -> None:
user_email = consume_magic_token(token)
user = gr_api.ensure_user_exists(User.model_validate({"email": user_email}))
+
+ # 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(
key=SESSION_COOKIE_NAME,
value=create_session(user.product_user_id),
@@ -161,6 +218,8 @@ 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:
@@ -175,9 +234,15 @@ 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,
+ background_tasks=background_tasks,
+ )
return HTMLResponse(
render_base_html(),
@@ -191,25 +256,46 @@ 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)],
) -> 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,
+ 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):
+def _exchange_amt_account_link(
+ 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
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)
+ 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)
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
new file mode 100644
index 0000000..681cc21
--- /dev/null
+++ b/jb/views/wallet.py
@@ -0,0 +1,132 @@
+from datetime import timedelta
+from typing import Annotated
+from urllib.parse import urlencode
+
+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.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,
+ CashoutPostback,
+ 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}"
+
+ cm = get_cashout_method(cashout_method_id=body.cashout_method_id)
+
+ if settings.debug:
+ return {"confirmation_link": confirmation_link, "cashout_method": cm.id}
+
+ 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."}
+
+
+@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,
+ user: Annotated[User, Depends(get_authenticated_user)],
+) -> 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,
+ )
+
+
+@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
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;
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