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/models/wallet.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 jb/models/wallet.py (limited to 'jb/models/wallet.py') 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 -- 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(-) (limited to 'jb/models/wallet.py') 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(-) (limited to 'jb/models/wallet.py') 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