diff options
| -rw-r--r-- | jb-ui/src/JBApp.tsx | 11 | ||||
| -rw-r--r-- | jb/api/cashout_token.py | 43 | ||||
| -rw-r--r-- | jb/main.py | 2 | ||||
| -rw-r--r-- | jb/managers/email_manager.py | 9 | ||||
| -rw-r--r-- | jb/managers/thl.py | 20 | ||||
| -rw-r--r-- | jb/models/wallet.py | 24 | ||||
| -rw-r--r-- | jb/views/wallet.py | 68 |
7 files changed, 168 insertions, 9 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/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) @@ -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, + ) |
