🐐
This commit is contained in:
189
backend/routers/store.py
Normal file
189
backend/routers/store.py
Normal file
@@ -0,0 +1,189 @@
|
||||
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}
|
||||
Reference in New Issue
Block a user