107 lines
3.2 KiB
Python
107 lines
3.2 KiB
Python
"""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,
|
|
)
|