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()
|
||||
Reference in New Issue
Block a user