Files
wiki-tcg/backend/routers/store.py
2026-04-01 18:31:33 +02:00

190 lines
6.1 KiB
Python

import uuid
from datetime import datetime
import stripe
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from game.card import _get_specific_card_async
from core.config import FRONTEND_URL, STRIPE_PUBLISHABLE_KEY, STRIPE_WEBHOOK_SECRET
from core.database import get_db
from core.dependencies import get_current_user, limiter
from core.models import Card as CardModel
from core.models import ProcessedWebhookEvent
from core.models import User as UserModel
router = APIRouter()
# Shard packages sold for real money.
# price_oere is in Danish øre (1 DKK = 100 øre). Stripe minimum is 250 øre.
SHARD_PACKAGES = {
"s1": {"base": 100, "bonus": 0, "shards": 100, "price_oere": 1000, "price_label": "10 DKK"},
"s2": {"base": 250, "bonus": 50, "shards": 300, "price_oere": 2500, "price_label": "25 DKK"},
"s3": {"base": 500, "bonus": 200, "shards": 700, "price_oere": 5000, "price_label": "50 DKK"},
"s4": {"base": 1000, "bonus": 600, "shards": 1600, "price_oere": 10000, "price_label": "100 DKK"},
"s5": {"base": 2500, "bonus": 2000, "shards": 4500, "price_oere": 25000, "price_label": "250 DKK"},
"s6": {"base": 5000, "bonus": 5000, "shards": 10000, "price_oere": 50000, "price_label": "500 DKK"},
}
STORE_PACKAGES = {
1: 15,
5: 65,
10: 120,
25: 260,
}
SPECIFIC_CARD_COST = 1000
class ShatterRequest(BaseModel):
card_ids: list[str]
class StripeCheckoutRequest(BaseModel):
package_id: str
class StoreBuyRequest(BaseModel):
quantity: int
class BuySpecificCardRequest(BaseModel):
wiki_title: str
@router.post("/shards/shatter")
def shatter_cards(req: ShatterRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
if not req.card_ids:
raise HTTPException(status_code=400, detail="No cards selected")
try:
parsed_ids = [uuid.UUID(cid) for cid in req.card_ids]
except ValueError:
raise HTTPException(status_code=400, detail="Invalid card IDs")
cards = db.query(CardModel).filter(
CardModel.id.in_(parsed_ids),
CardModel.user_id == user.id,
).all()
if len(cards) != len(parsed_ids):
raise HTTPException(status_code=400, detail="Some cards are not in your collection")
total = sum(c.cost for c in cards)
for card in cards:
db.delete(card)
user.shards += total
db.commit()
return {"shards": user.shards, "gained": total}
@router.post("/store/stripe/checkout")
def create_stripe_checkout(req: StripeCheckoutRequest, user: UserModel = Depends(get_current_user)):
package = SHARD_PACKAGES.get(req.package_id)
if not package:
raise HTTPException(status_code=400, detail="Invalid package")
session = stripe.checkout.Session.create(
payment_method_types=["card"],
line_items=[{
"price_data": {
"currency": "dkk",
"product_data": {"name": f"WikiTCG Shards — {package['price_label']}"},
"unit_amount": package["price_oere"],
},
"quantity": 1,
}],
mode="payment",
success_url=f"{FRONTEND_URL}/store?payment=success",
cancel_url=f"{FRONTEND_URL}/store",
metadata={"user_id": str(user.id), "shards": str(package["shards"])},
)
return {"url": session.url}
@router.post("/stripe/webhook")
async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
payload = await request.body()
sig = request.headers.get("stripe-signature", "")
try:
event = stripe.Webhook.construct_event(payload, sig, STRIPE_WEBHOOK_SECRET)
except stripe.error.SignatureVerificationError: # type: ignore
raise HTTPException(status_code=400, detail="Invalid signature")
# Guard against duplicate delivery: Stripe retries on timeout/5xx, so the same
# event can arrive more than once. The PK constraint on stripe_event_id is the
# arbiter — if the INSERT fails, we've already processed this event.
try:
db.add(ProcessedWebhookEvent(stripe_event_id=event["id"]))
db.flush()
except IntegrityError:
db.rollback()
return {"ok": True}
if event["type"] == "checkout.session.completed":
data = event["data"]["object"]
user_id = data.get("metadata", {}).get("user_id")
shards = data.get("metadata", {}).get("shards")
if user_id and shards:
user = db.query(UserModel).filter(UserModel.id == uuid.UUID(user_id)).first()
if user:
user.shards += int(shards)
db.commit()
return {"ok": True}
@router.get("/store/config")
def store_config():
return {
"publishable_key": STRIPE_PUBLISHABLE_KEY,
"shard_packages": SHARD_PACKAGES,
}
@router.post("/store/buy-specific-card")
@limiter.limit("10/hour")
async def buy_specific_card(request: Request, req: BuySpecificCardRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
if user.shards < SPECIFIC_CARD_COST:
raise HTTPException(status_code=400, detail="Not enough shards")
card = await _get_specific_card_async(req.wiki_title)
if card is None:
raise HTTPException(status_code=404, detail="Could not generate a card for that Wikipedia page")
db_card = CardModel(
name=card.name,
image_link=card.image_link,
card_rarity=card.card_rarity.name,
card_type=card.card_type.name,
text=card.text,
attack=card.attack,
defense=card.defense,
cost=card.cost,
user_id=user.id,
received_at=datetime.now(),
)
db.add(db_card)
user.shards -= SPECIFIC_CARD_COST
db.commit()
db.refresh(db_card)
return {
**{c.name: getattr(db_card, c.name) for c in db_card.__table__.columns},
"card_rarity": db_card.card_rarity,
"card_type": db_card.card_type,
"shards": user.shards,
}
@router.post("/store/buy")
def store_buy(req: StoreBuyRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
cost = STORE_PACKAGES.get(req.quantity)
if cost is None:
raise HTTPException(status_code=400, detail="Invalid package")
if user.shards < cost:
raise HTTPException(status_code=400, detail="Not enough shards")
user.shards -= cost
user.boosters += req.quantity
db.commit()
return {"shards": user.shards, "boosters": user.boosters}