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}