From 2b78729e6bcb0d9d98c76ffdcc86b996ed901584 Mon Sep 17 00:00:00 2001 From: daddy Date: Tue, 15 Sep 2026 19:56:24 +0000 Subject: [PATCH] Implement PaymentProvider interface, NoopPaymentProvider, and purchase/confirm endpoints (task 575fd14b5360) --- backend/app/main.py | 3 +- backend/app/routers/payments.py | 106 +++++++++++++++++++++++++++ backend/app/services/payment.py | 89 +++++++++++++++++++++++ backend/tests/test_payments.py | 123 ++++++++++++++++++++++++++++++++ 4 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 backend/app/routers/payments.py create mode 100644 backend/app/services/payment.py create mode 100644 backend/tests/test_payments.py diff --git a/backend/app/main.py b/backend/app/main.py index b7b32c0..4ba9f69 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,7 +9,7 @@ from app.config import settings from app.routers import ( auth, users, worlds, assets, avatar, world_content, internal, presence, groups, social_friends, profiles, chat_dm, chat_reports, - chat_world, wallet, + chat_world, wallet, payments, ) from app.gateway import ws @@ -59,6 +59,7 @@ app.include_router(chat_reports.moderation_router, prefix="/api/moderation", tag app.include_router(chat_world.router, prefix="/api/chat", tags=["chat-world"]) app.include_router(chat_world.router, prefix="/v1/chat", tags=["chat-world"]) app.include_router(wallet.router, prefix="/api/wallet", tags=["wallet"]) +app.include_router(payments.router, prefix="/api/payments", tags=["payments"]) app.include_router(ws.router, prefix="/api", tags=["websocket"]) app.include_router(ws.router, prefix="/v1", tags=["websocket"]) diff --git a/backend/app/routers/payments.py b/backend/app/routers/payments.py new file mode 100644 index 0000000..d0789f2 --- /dev/null +++ b/backend/app/routers/payments.py @@ -0,0 +1,106 @@ +"""Payments API endpoints for purchasing Credits. + +docs/planning/economy.md US-2 (task 575fd14b5360). +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Header, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.dependencies import CurrentUser +from app.models.wallet import TransactionType +from app.services.payment import ALLOWED_CREDIT_PACKS, payment_provider +from app.services.wallet import wallet_service + +router = APIRouter() + +DbDep = Annotated[AsyncSession, Depends(get_db)] + + +class PurchaseRequest(BaseModel): + credits: int = Field(..., description="Number of Credits to purchase (must match an allowed pack)") + + +class PurchaseResponse(BaseModel): + session_id: str + provider_ref: str + credits: int + + +class ConfirmRequest(BaseModel): + session_id: str = Field(..., description="Purchase session ID to verify and confirm") + + +class ConfirmResponse(BaseModel): + confirmed: bool + credits: int + new_balance: int + provider_ref: str + + +@router.post("/purchase", response_model=PurchaseResponse, status_code=status.HTTP_201_CREATED) +async def create_purchase_session( + payload: PurchaseRequest, + current_user: CurrentUser, + idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None, +): + """Initiate a Credit purchase session via the payment provider.""" + if payload.credits not in ALLOWED_CREDIT_PACKS: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Invalid credit pack: {payload.credits}. Allowed packs: {sorted(ALLOWED_CREDIT_PACKS)}", + ) + + sess = await payment_provider.create_purchase( + user_id=current_user.id, + credits=payload.credits, + idempotency_key=idempotency_key, + ) + return PurchaseResponse( + session_id=sess.session_id, + provider_ref=sess.provider_ref, + credits=sess.credits, + ) + + +@router.post("/confirm", response_model=ConfirmResponse) +async def confirm_purchase( + payload: ConfirmRequest, + current_user: CurrentUser, + db: DbDep, +): + """Verify purchase session with provider and credit the user's wallet. + + Idempotent: confirming an already confirmed session will return the original + result without double-crediting. + """ + conf = await payment_provider.confirm(payload.session_id) + if not conf.confirmed or conf.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Payment confirmation failed, invalid session, or unauthorized.", + ) + + # Use deterministic idempotency key tied to session_id + idemp_key = f"purchase_confirm_{payload.session_id}" + + wallet, tx = await wallet_service.credit( + db, + user_id=current_user.id, + amount=conf.credits, + tx_type=TransactionType.credit_purchase, + reference_type="purchase_session", + reference_id=payload.session_id, + idempotency_key=idemp_key, + metadata={"provider_ref": conf.provider_ref}, + ) + + return ConfirmResponse( + confirmed=True, + credits=conf.credits, + new_balance=wallet.balance, + provider_ref=conf.provider_ref, + ) diff --git a/backend/app/services/payment.py b/backend/app/services/payment.py new file mode 100644 index 0000000..a4140df --- /dev/null +++ b/backend/app/services/payment.py @@ -0,0 +1,89 @@ +"""PaymentProvider interface and NoopPaymentProvider implementation. + +docs/planning/economy.md US-2 (task 575fd14b5360). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Protocol +import uuid + +ALLOWED_CREDIT_PACKS: set[int] = {100, 500, 1000, 5000} + + +@dataclass +class PurchaseSession: + session_id: str + user_id: int + credits: int + provider_ref: str + status: str = "created" # created, confirmed, failed + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +@dataclass +class Confirmation: + confirmed: bool + provider_ref: str + credits: int + user_id: int + + +class PaymentProvider(Protocol): + """Pluggable payment provider protocol (e.g. Stripe, PayPal, Noop).""" + + async def create_purchase( + self, user_id: int, credits: int, idempotency_key: str | None = None + ) -> PurchaseSession: + ... + + async def confirm(self, session_id: str) -> Confirmation: + ... + + +class NoopPaymentProvider: + """Deterministic, no-op payment provider for local development and testing. + + Returns confirmed=True with a simulated provider reference. Clearly non-production. + """ + + def __init__(self) -> None: + self._sessions: dict[str, PurchaseSession] = {} + + async def create_purchase( + self, user_id: int, credits: int, idempotency_key: str | None = None + ) -> PurchaseSession: + session_id = f"sess_{uuid.uuid4().hex[:16]}" + provider_ref = f"noop_tx_{uuid.uuid4().hex[:12]}" + sess = PurchaseSession( + session_id=session_id, + user_id=user_id, + credits=credits, + provider_ref=provider_ref, + status="created", + ) + self._sessions[session_id] = sess + return sess + + async def confirm(self, session_id: str) -> Confirmation: + sess = self._sessions.get(session_id) + if sess is None: + return Confirmation( + confirmed=False, + provider_ref="", + credits=0, + user_id=0, + ) + + sess.status = "confirmed" + return Confirmation( + confirmed=True, + provider_ref=sess.provider_ref, + credits=sess.credits, + user_id=sess.user_id, + ) + + +payment_provider: PaymentProvider = NoopPaymentProvider() diff --git a/backend/tests/test_payments.py b/backend/tests/test_payments.py new file mode 100644 index 0000000..1e180f9 --- /dev/null +++ b/backend/tests/test_payments.py @@ -0,0 +1,123 @@ +"""Integration tests for Premium Purchase and PaymentProvider. + +docs/planning/economy.md US-2 (task 575fd14b5360). +""" + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + + +def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]: + resp = client.post( + "/api/auth/register", + json={ + "username": username, + "email": f"{username}@example.com", + "password": "secret123", + "date_of_birth": "1990-01-01", + }, + ) + assert resp.status_code == 201, resp.text + token = resp.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + me = client.get("/api/users/me", headers=headers) + assert me.status_code == 200, me.text + return me.json()["id"], headers + + +def test_payments_unauthorized(): + client = TestClient(app) + resp = client.post("/api/payments/purchase", json={"credits": 500}) + assert resp.status_code == 401 + + resp = client.post("/api/payments/confirm", json={"session_id": "sess_123"}) + assert resp.status_code == 401 + + +def test_purchase_rejects_arbitrary_amounts(): + client = TestClient(app) + _, headers = _register(client, "pay_user_1") + + # 42 credits is not an allowed pack + resp = client.post("/api/payments/purchase", json={"credits": 42}, headers=headers) + assert resp.status_code == 422 + + # Negative / 0 credits + resp = client.post("/api/payments/purchase", json={"credits": -100}, headers=headers) + assert resp.status_code == 422 + + +def test_purchase_and_confirm_flow(): + client = TestClient(app) + uid, headers = _register(client, "pay_user_2") + + # 1. Check initial balance + w_resp = client.get("/api/wallet", headers=headers) + assert w_resp.status_code == 200 + assert w_resp.json()["balance"] == 0 + + # 2. Purchase session for 500 credits + p_resp = client.post( + "/api/payments/purchase", + json={"credits": 500}, + headers={"Idempotency-Key": "sess_req_1", **headers}, + ) + assert p_resp.status_code == 201, p_resp.text + p_data = p_resp.json() + assert p_data["credits"] == 500 + assert p_data["session_id"].startswith("sess_") + session_id = p_data["session_id"] + + # Balance is still 0 before confirmation + w_resp = client.get("/api/wallet", headers=headers) + assert w_resp.json()["balance"] == 0 + + # 3. Confirm purchase session + c_resp = client.post( + "/api/payments/confirm", + json={"session_id": session_id}, + headers=headers, + ) + assert c_resp.status_code == 200, c_resp.text + c_data = c_resp.json() + assert c_data["confirmed"] is True + assert c_data["credits"] == 500 + assert c_data["new_balance"] == 500 + assert c_data["provider_ref"] != "" + + # 4. Idempotent re-confirm must not double-credit + c_resp2 = client.post( + "/api/payments/confirm", + json={"session_id": session_id}, + headers=headers, + ) + assert c_resp2.status_code == 200 + assert c_resp2.json()["new_balance"] == 500 + + # Check transactions in ledger: exactly 1 transaction + tx_resp = client.get("/api/wallet/transactions", headers=headers) + assert tx_resp.status_code == 200 + tx_data = tx_resp.json() + assert tx_data["total"] == 1 + assert tx_data["transactions"][0]["amount"] == 500 + assert tx_data["transactions"][0]["type"] == "credit_purchase" + + +def test_confirm_invalid_or_wrong_user_fails(): + client = TestClient(app) + uid1, headers1 = _register(client, "pay_user_3a") + uid2, headers2 = _register(client, "pay_user_3b") + + # User 1 creates a session + p_resp = client.post("/api/payments/purchase", json={"credits": 100}, headers=headers1) + session_id = p_resp.json()["session_id"] + + # User 2 tries to confirm User 1's session -> must fail 400 + bad_resp = client.post("/api/payments/confirm", json={"session_id": session_id}, headers=headers2) + assert bad_resp.status_code == 400 + + # Non-existent session -> must fail 400 + bad_resp2 = client.post("/api/payments/confirm", json={"session_id": "sess_nonexistent"}, headers=headers1) + assert bad_resp2.status_code == 400