Implement PaymentProvider interface, NoopPaymentProvider, and purchase/confirm endpoints (task 575fd14b5360)
This commit is contained in:
+2
-1
@@ -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"])
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user