58ca4e68db
- Go backend API with full CRUD for all modules (CRM, Sales, Finance, HR, Legal, Marketing, Support, Purchase, Inventory, Projects, Automation, Analytics) - Rust analytics service with parallel report generation - C runtime with POSIX shared memory IPC - PostgreSQL schema with 30+ tables, full migrations - Redis cache, sessions, pub/sub - Kafka event streaming with Zookeeper - WebSocket hub for real-time updates - Automation engine with cron jobs, workflows, event triggers - JWT authentication, multi-tenant from start - Docker Compose with all services - Nginx reverse proxy with rate limiting - Integration tests passing - Feature gap analysis against Fortnox/Odoo/Visma Refs: BOC-001
746 lines
29 KiB
Python
746 lines
29 KiB
Python
"""
|
||
quiXzoom Shop Service
|
||
E-handel för quiXzoom Field Gear Collection
|
||
|
||
quiXzoom Field Program:
|
||
- 0–9 uppdrag: Contributor (digital profil)
|
||
- 10+ uppdrag: Field Contributor (låser upp Field Gear Store)
|
||
- 100+ uppdrag med 95% godkännandegrad: Verified Field Contributor (låser upp Field ID Kit)
|
||
"""
|
||
|
||
import os
|
||
from datetime import datetime
|
||
from typing import List, Optional
|
||
from contextlib import asynccontextmanager
|
||
|
||
from fastapi import FastAPI, HTTPException, Depends, Request, status
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import JSONResponse, HTMLResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
from fastapi.templating import Jinja2Templates
|
||
from pydantic import BaseModel, Field
|
||
import asyncpg
|
||
import redis.asyncio as redis
|
||
import stripe
|
||
import qrcode
|
||
import io
|
||
import base64
|
||
|
||
# --- Config ---
|
||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/quixzoom_shop")
|
||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||
JWT_SECRET = os.getenv("JWT_SECRET", "quixzoom_secret")
|
||
SHOP_MISSION_THRESHOLD = int(os.getenv("SHOP_MISSION_THRESHOLD", "10"))
|
||
VERIFIED_MISSION_THRESHOLD = int(os.getenv("VERIFIED_MISSION_THRESHOLD", "100"))
|
||
VERIFIED_APPROVAL_RATE = float(os.getenv("VERIFIED_APPROVAL_RATE", "0.95"))
|
||
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "")
|
||
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET", "")
|
||
|
||
if STRIPE_SECRET_KEY:
|
||
stripe.api_key = STRIPE_SECRET_KEY
|
||
|
||
# --- Database ---
|
||
pool: asyncpg.Pool = None
|
||
redis_client: redis.Redis = None
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
global pool, redis_client
|
||
pool = await asyncpg.create_pool(DATABASE_URL, min_size=5, max_size=20)
|
||
redis_client = redis.from_url(REDIS_URL, decode_responses=True)
|
||
await init_db()
|
||
yield
|
||
await pool.close()
|
||
await redis_client.close()
|
||
|
||
async def init_db():
|
||
"""Create shop tables if not exist"""
|
||
async with pool.acquire() as conn:
|
||
await conn.execute("""
|
||
CREATE TABLE IF NOT EXISTS shop_products (
|
||
id SERIAL PRIMARY KEY,
|
||
sku VARCHAR(50) UNIQUE NOT NULL,
|
||
name VARCHAR(255) NOT NULL,
|
||
description TEXT,
|
||
price_sek INTEGER NOT NULL,
|
||
image_url VARCHAR(500),
|
||
category VARCHAR(100),
|
||
sizes JSONB DEFAULT '[]',
|
||
colors JSONB DEFAULT '[]',
|
||
stock_quantity INTEGER DEFAULT 0,
|
||
min_missions INTEGER DEFAULT 10,
|
||
is_active BOOLEAN DEFAULT true,
|
||
created_at TIMESTAMP DEFAULT NOW(),
|
||
updated_at TIMESTAMP DEFAULT NOW()
|
||
)
|
||
""")
|
||
await conn.execute("""
|
||
CREATE TABLE IF NOT EXISTS shop_orders (
|
||
id SERIAL PRIMARY KEY,
|
||
user_id VARCHAR(100) NOT NULL,
|
||
user_email VARCHAR(255) NOT NULL,
|
||
status VARCHAR(50) DEFAULT 'pending',
|
||
total_amount INTEGER NOT NULL,
|
||
shipping_address JSONB NOT NULL,
|
||
items JSONB NOT NULL,
|
||
stripe_payment_intent_id VARCHAR(255),
|
||
created_at TIMESTAMP DEFAULT NOW(),
|
||
updated_at TIMESTAMP DEFAULT NOW()
|
||
)
|
||
""")
|
||
await conn.execute("""
|
||
CREATE INDEX IF NOT EXISTS idx_shop_orders_user ON shop_orders(user_id)
|
||
""")
|
||
|
||
# Create zoomer_profiles table if not exists (for verification)
|
||
await conn.execute("""
|
||
CREATE TABLE IF NOT EXISTS zoomer_profiles (
|
||
user_id VARCHAR(100) PRIMARY KEY,
|
||
display_name VARCHAR(255),
|
||
contributor_id VARCHAR(50) UNIQUE,
|
||
completed_missions INTEGER DEFAULT 0,
|
||
identity_verified BOOLEAN DEFAULT false,
|
||
created_at TIMESTAMP DEFAULT NOW()
|
||
)
|
||
""")
|
||
|
||
# Seed products if empty
|
||
count = await conn.fetchval("SELECT COUNT(*) FROM shop_products")
|
||
if count == 0:
|
||
await seed_products(conn)
|
||
|
||
async def seed_products(conn: asyncpg.Connection):
|
||
"""Seed initial Field Gear Collection"""
|
||
products = [
|
||
("VEST-001", "Field Vest", "Taktisk väst med flera fickor för utrustning", 129900, "vest.png", "kläder", '["S","M","L","XL"]', '["Olive","Black"]', 50, 10),
|
||
("JACKET-001", "Mission Shell", "Vattentät skaljacka för alla väder", 249900, "jacket.png", "kläder", '["S","M","L","XL"]', '["Olive","Black","Tan"]', 30, 10),
|
||
("PANTS-001", "Field Pants", "Robusta fältbyxor med förstärkta knän", 179900, "pants.png", "kläder", '["S","M","L","XL","XXL"]', '["Olive","Black","Tan"]', 40, 10),
|
||
("BASE-001", "Base Layer", "Sömlös underställströja", 89900, "base.png", "kläder", '["S","M","L","XL"]', '["Black","Grey"]', 60, 10),
|
||
("BEANIE-001", "Field Beanie", "Varm mössa med quiXzoom-logo", 49900, "beanie.png", "accessoarer", '["One Size"]', '["Olive","Black","Grey"]', 100, 10),
|
||
("GLOVES-001", "Field Gloves", "Taktiska handskar med touch-funktion", 79900, "gloves.png", "accessoarer", '["S","M","L","XL"]', '["Black","Tan"]', 80, 10),
|
||
("STRAP-001", "Capture Strap", "Justerbar kamerarem", 59900, "strap.png", "utrustning", '["One Size"]', '["Black","Tan"]', 75, 10),
|
||
("POUCH-001", "Utility Pouch", "Modulär väska för tillbehör", 69900, "pouch.png", "utrustning", '["One Size"]', '["Olive","Black"]', 60, 10),
|
||
("COIN-001", "Ref. Coin", "quiXzoom referensmynt", 19900, "coin.png", "accessoarer", '["One Size"]', '["Silver","Black"]', 200, 10),
|
||
("LIGHT-001", "Pocket Light", "Kompakt ficklampa", 39900, "light.png", "utrustning", '["One Size"]', '["Black"]', 90, 10),
|
||
("POWER-001", "Power Module", "Powerbank 10000mAh", 149900, "power.png", "utrustning", '["One Size"]', '["Black"]', 45, 10),
|
||
("CABLE-001", "Cable Kit", "Kabelset USB-C/Lightning", 59900, "cable.png", "utrustning", '["One Size"]', '["Black"]', 100, 10),
|
||
("RAIN-001", "Rain Cover", "Regnskydd för utrustning", 79900, "rain.png", "utrustning", '["One Size"]', '["Olive","Black"]', 55, 10),
|
||
("BAG-001", "Field Bag", "Stor fältväska", 199900, "bag.png", "utrustning", '["One Size"]', '["Olive","Black","Tan"]', 25, 10),
|
||
("BELT-001", "Tactical Belt", "Taktiskt bälte", 89900, "belt.png", "accessoarer", '["S","M","L","XL"]', '["Black","Tan"]', 50, 10),
|
||
("SOCKS-001", "Field Socks", "Trepack strumpor", 39900, "socks.png", "kläder", '["S","M","L"]', '["Black","Grey"]', 120, 10),
|
||
("PATCH-001", "quiXzoom Patch", "Velcro-märke", 29900, "patch.png", "accessoarer", '["One Size"]', '["Olive","Black"]', 150, 10),
|
||
("BOTTLE-001", "Field Bottle", "Isolerad vattenflaska", 69900, "bottle.png", "utrustning", '["One Size"]', '["Olive","Black","Tan"]', 70, 10),
|
||
# Verified Field Contributor — Field ID Kit (min_missions = 100)
|
||
("ID-KIT-001", "Field ID Kit", "Personligt verifierat ID-kort med hållare och lanyard", 0, "id-kit.png", "verified", '["One Size"]', '["Black"]', 999, 100),
|
||
]
|
||
await conn.executemany("""
|
||
INSERT INTO shop_products (sku, name, description, price_sek, image_url, category, sizes, colors, stock_quantity, min_missions)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||
""", products)
|
||
|
||
# --- Auth ---
|
||
from jose import jwt, JWTError
|
||
|
||
async def get_current_user(token: str = None):
|
||
"""Validate JWT and return user info"""
|
||
if not token:
|
||
raise HTTPException(status_code=401, detail="Authentication required")
|
||
try:
|
||
payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
|
||
return payload
|
||
except JWTError:
|
||
raise HTTPException(status_code=401, detail="Invalid token")
|
||
|
||
async def get_user_stats(user_id: str) -> dict:
|
||
"""Get user mission stats for tier calculation"""
|
||
cache_key = f"user_stats:{user_id}"
|
||
cached = await redis_client.get(cache_key)
|
||
if cached:
|
||
import json
|
||
return json.loads(cached)
|
||
|
||
async with pool.acquire() as conn:
|
||
# Get completed missions count
|
||
completed = await conn.fetchval("""
|
||
SELECT COUNT(*) FROM mission_submissions
|
||
WHERE user_id = $1 AND status = 'approved'
|
||
""", user_id) or 0
|
||
|
||
# Get approval rate for last 100 missions
|
||
last_100 = await conn.fetch("""
|
||
SELECT status FROM mission_submissions
|
||
WHERE user_id = $1
|
||
ORDER BY created_at DESC
|
||
LIMIT 100
|
||
""", user_id)
|
||
|
||
approved_last_100 = sum(1 for r in last_100 if r['status'] == 'approved')
|
||
total_last_100 = len(last_100)
|
||
approval_rate = approved_last_100 / total_last_100 if total_last_100 > 0 else 1.0
|
||
|
||
# Check identity verification status
|
||
identity_verified = await conn.fetchval("""
|
||
SELECT identity_verified FROM zoomer_profiles
|
||
WHERE user_id = $1
|
||
""", user_id) or False
|
||
|
||
# Check for policy violations
|
||
violations = await conn.fetchval("""
|
||
SELECT COUNT(*) FROM policy_violations
|
||
WHERE user_id = $1 AND severity = 'serious'
|
||
""", user_id) or 0
|
||
|
||
stats = {
|
||
"completed_missions": completed,
|
||
"approval_rate": approval_rate,
|
||
"identity_verified": identity_verified,
|
||
"serious_violations": violations,
|
||
}
|
||
|
||
await redis_client.setex(cache_key, 300, json.dumps(stats))
|
||
return stats
|
||
|
||
async def check_mission_eligibility(user_id: str) -> bool:
|
||
"""Check if user has completed enough missions for Field Gear Store"""
|
||
stats = await get_user_stats(user_id)
|
||
return stats["completed_missions"] >= SHOP_MISSION_THRESHOLD
|
||
|
||
async def check_verified_eligibility(user_id: str) -> bool:
|
||
"""Check if user qualifies for Verified Field Contributor (Field ID Kit)"""
|
||
stats = await get_user_stats(user_id)
|
||
return (
|
||
stats["completed_missions"] >= VERIFIED_MISSION_THRESHOLD and
|
||
stats["approval_rate"] >= VERIFIED_APPROVAL_RATE and
|
||
stats["identity_verified"] and
|
||
stats["serious_violations"] == 0
|
||
)
|
||
|
||
# --- Pydantic Models ---
|
||
class ProductOut(BaseModel):
|
||
id: int
|
||
sku: str
|
||
name: str
|
||
description: Optional[str]
|
||
price_sek: int
|
||
image_url: Optional[str]
|
||
category: str
|
||
sizes: List[str]
|
||
colors: List[str]
|
||
stock_quantity: int
|
||
min_missions: int
|
||
is_active: bool
|
||
|
||
class CartItem(BaseModel):
|
||
product_id: int
|
||
quantity: int = Field(ge=1, le=10)
|
||
size: Optional[str] = None
|
||
color: Optional[str] = None
|
||
|
||
class ShippingAddress(BaseModel):
|
||
name: str
|
||
street: str
|
||
city: str
|
||
postal_code: str
|
||
country: str = "SE"
|
||
phone: Optional[str] = None
|
||
|
||
class OrderCreate(BaseModel):
|
||
items: List[CartItem]
|
||
shipping_address: ShippingAddress
|
||
|
||
class OrderOut(BaseModel):
|
||
id: int
|
||
status: str
|
||
total_amount: int
|
||
items: list
|
||
created_at: datetime
|
||
|
||
class PaymentIntentRequest(BaseModel):
|
||
order_id: int
|
||
|
||
class PaymentIntentResponse(BaseModel):
|
||
client_secret: str
|
||
publishable_key: str
|
||
|
||
class UserTierResponse(BaseModel):
|
||
tier: str # "contributor", "field_contributor", "verified_field_contributor"
|
||
completed_missions: int
|
||
approval_rate: float
|
||
identity_verified: bool
|
||
serious_violations: int
|
||
unlocked_features: List[str]
|
||
|
||
# --- FastAPI App ---
|
||
app = FastAPI(
|
||
title="quiXzoom Shop",
|
||
description="E-handel för quiXzoom Field Gear Collection",
|
||
version="1.0.0",
|
||
lifespan=lifespan
|
||
)
|
||
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=[
|
||
"https://quixzoom.se",
|
||
"https://www.quixzoom.se",
|
||
"https://quixzoom.com",
|
||
"https://www.quixzoom.com",
|
||
"http://localhost:3000",
|
||
"http://localhost:8080",
|
||
],
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
# Static files and templates
|
||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||
templates = Jinja2Templates(directory="templates")
|
||
|
||
# --- API Endpoints ---
|
||
|
||
@app.get("/health")
|
||
async def health():
|
||
return {"status": "ok", "service": "quixzoom-shop"}
|
||
|
||
@app.get("/api/eligibility")
|
||
async def check_eligibility(request: Request):
|
||
"""Check if user is eligible to shop (legacy, use /api/tier instead)"""
|
||
auth_header = request.headers.get("authorization", "")
|
||
token = auth_header.replace("Bearer ", "") if auth_header.startswith("Bearer ") else None
|
||
|
||
if not token:
|
||
return {"eligible": False, "missions": 0, "required": SHOP_MISSION_THRESHOLD}
|
||
|
||
try:
|
||
user = await get_current_user(token)
|
||
user_id = user.get("sub") or user.get("user_id")
|
||
stats = await get_user_stats(user_id)
|
||
|
||
return {
|
||
"eligible": stats["completed_missions"] >= SHOP_MISSION_THRESHOLD,
|
||
"missions": stats["completed_missions"],
|
||
"required": SHOP_MISSION_THRESHOLD
|
||
}
|
||
except Exception:
|
||
return {"eligible": False, "missions": 0, "required": SHOP_MISSION_THRESHOLD}
|
||
|
||
@app.get("/api/tier", response_model=UserTierResponse)
|
||
async def get_user_tier(request: Request):
|
||
"""Get user's Field Program tier and unlocked features"""
|
||
auth_header = request.headers.get("authorization", "")
|
||
token = auth_header.replace("Bearer ", "") if auth_header.startswith("Bearer ") else None
|
||
|
||
if not token:
|
||
raise HTTPException(status_code=401, detail="Authentication required")
|
||
|
||
user = await get_current_user(token)
|
||
user_id = user.get("sub") or user.get("user_id")
|
||
stats = await get_user_stats(user_id)
|
||
|
||
# Determine tier
|
||
if await check_verified_eligibility(user_id):
|
||
tier = "verified_field_contributor"
|
||
unlocked = ["field_gear_store", "field_id_kit", "verified_badge", "public_profile"]
|
||
elif await check_mission_eligibility(user_id):
|
||
tier = "field_contributor"
|
||
unlocked = ["field_gear_store"]
|
||
else:
|
||
tier = "contributor"
|
||
unlocked = ["digital_profile"]
|
||
|
||
return {
|
||
"tier": tier,
|
||
"completed_missions": stats["completed_missions"],
|
||
"approval_rate": stats["approval_rate"],
|
||
"identity_verified": stats["identity_verified"],
|
||
"serious_violations": stats["serious_violations"],
|
||
"unlocked_features": unlocked
|
||
}
|
||
|
||
@app.get("/api/products", response_model=List[ProductOut])
|
||
async def list_products(category: Optional[str] = None, request: Request = None):
|
||
"""List all active products. Verified-only products filtered based on user tier."""
|
||
# Check user tier for verified products
|
||
show_verified = False
|
||
if request:
|
||
auth_header = request.headers.get("authorization", "")
|
||
token = auth_header.replace("Bearer ", "") if auth_header.startswith("Bearer ") else None
|
||
if token:
|
||
try:
|
||
user = await get_current_user(token)
|
||
user_id = user.get("sub") or user.get("user_id")
|
||
show_verified = await check_verified_eligibility(user_id)
|
||
except Exception:
|
||
pass
|
||
|
||
async with pool.acquire() as conn:
|
||
if category:
|
||
rows = await conn.fetch(
|
||
"SELECT * FROM shop_products WHERE is_active = true AND category = $1 ORDER BY category, name",
|
||
category
|
||
)
|
||
else:
|
||
rows = await conn.fetch(
|
||
"SELECT * FROM shop_products WHERE is_active = true ORDER BY category, name"
|
||
)
|
||
|
||
products = [parse_product(r) for r in rows]
|
||
|
||
# Filter verified-only products if user is not verified
|
||
if not show_verified:
|
||
products = [p for p in products if p["category"] != "verified"]
|
||
|
||
return products
|
||
|
||
def parse_product(row):
|
||
"""Parse product row with JSON fields"""
|
||
import json
|
||
d = dict(row)
|
||
if isinstance(d.get('sizes'), str):
|
||
d['sizes'] = json.loads(d['sizes'])
|
||
if isinstance(d.get('colors'), str):
|
||
d['colors'] = json.loads(d['colors'])
|
||
return d
|
||
|
||
@app.get("/api/products/{product_id}", response_model=ProductOut)
|
||
async def get_product(product_id: int):
|
||
"""Get single product"""
|
||
async with pool.acquire() as conn:
|
||
row = await conn.fetchrow("SELECT * FROM shop_products WHERE id = $1 AND is_active = true", product_id)
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="Product not found")
|
||
return parse_product(row)
|
||
|
||
@app.post("/api/orders", response_model=OrderOut)
|
||
async def create_order(order: OrderCreate, request: Request):
|
||
"""Create new order (requires auth + mission eligibility)"""
|
||
# Get auth token from header
|
||
auth_header = request.headers.get("authorization", "")
|
||
token = auth_header.replace("Bearer ", "") if auth_header.startswith("Bearer ") else None
|
||
|
||
user = await get_current_user(token)
|
||
user_id = user.get("sub") or user.get("user_id")
|
||
user_email = user.get("email", "")
|
||
|
||
# Check mission eligibility
|
||
eligible = await check_mission_eligibility(user_id)
|
||
if not eligible:
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail=f"Du måste ha slutfört minst {SHOP_MISSION_THRESHOLD} uppdrag för att handla i shoppen."
|
||
)
|
||
|
||
# Validate items and calculate total
|
||
total = 0
|
||
order_items = []
|
||
|
||
async with pool.acquire() as conn:
|
||
async with conn.transaction():
|
||
for item in order.items:
|
||
product = await conn.fetchrow(
|
||
"SELECT * FROM shop_products WHERE id = $1 AND is_active = true",
|
||
item.product_id
|
||
)
|
||
if not product:
|
||
raise HTTPException(status_code=400, detail=f"Product {item.product_id} not found")
|
||
if product["stock_quantity"] < item.quantity:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Not enough stock for {product['name']}. Available: {product['stock_quantity']}"
|
||
)
|
||
|
||
# Check size/color availability
|
||
sizes = product["sizes"] or []
|
||
colors = product["colors"] or []
|
||
if item.size and sizes and item.size not in sizes:
|
||
raise HTTPException(status_code=400, detail=f"Size {item.size} not available")
|
||
if item.color and colors and item.color not in colors:
|
||
raise HTTPException(status_code=400, detail=f"Color {item.color} not available")
|
||
|
||
item_total = product["price_sek"] * item.quantity
|
||
total += item_total
|
||
|
||
order_items.append({
|
||
"product_id": item.product_id,
|
||
"sku": product["sku"],
|
||
"name": product["name"],
|
||
"quantity": item.quantity,
|
||
"size": item.size,
|
||
"color": item.color,
|
||
"unit_price": product["price_sek"],
|
||
"total": item_total
|
||
})
|
||
|
||
# Update stock
|
||
await conn.execute(
|
||
"UPDATE shop_products SET stock_quantity = stock_quantity - $1 WHERE id = $2",
|
||
item.quantity, item.product_id
|
||
)
|
||
|
||
# Create order
|
||
order_id = await conn.fetchval("""
|
||
INSERT INTO shop_orders (user_id, user_email, total_amount, shipping_address, items, status)
|
||
VALUES ($1, $2, $3, $4, $5, 'pending')
|
||
RETURNING id
|
||
""", user_id, user_email, total,
|
||
dict(order.shipping_address),
|
||
order_items)
|
||
|
||
# Clear cache
|
||
await redis_client.delete(f"missions:{user_id}")
|
||
|
||
return {"id": order_id, "status": "pending", "total_amount": total, "items": order_items, "created_at": datetime.now()}
|
||
|
||
@app.get("/api/orders", response_model=List[OrderOut])
|
||
async def list_orders(request: Request):
|
||
"""List user's orders"""
|
||
auth_header = request.headers.get("authorization", "")
|
||
token = auth_header.replace("Bearer ", "") if auth_header.startswith("Bearer ") else None
|
||
user = await get_current_user(token)
|
||
user_id = user.get("sub") or user.get("user_id")
|
||
|
||
async with pool.acquire() as conn:
|
||
rows = await conn.fetch(
|
||
"SELECT id, status, total_amount, items, created_at FROM shop_orders WHERE user_id = $1 ORDER BY created_at DESC",
|
||
user_id
|
||
)
|
||
return [dict(r) for r in rows]
|
||
|
||
@app.get("/api/orders/{order_id}", response_model=OrderOut)
|
||
async def get_order(order_id: int, request: Request):
|
||
"""Get single order"""
|
||
auth_header = request.headers.get("authorization", "")
|
||
token = auth_header.replace("Bearer ", "") if auth_header.startswith("Bearer ") else None
|
||
user = await get_current_user(token)
|
||
user_id = user.get("sub") or user.get("user_id")
|
||
|
||
async with pool.acquire() as conn:
|
||
row = await conn.fetchrow(
|
||
"SELECT id, status, total_amount, items, created_at FROM shop_orders WHERE id = $1 AND user_id = $2",
|
||
order_id, user_id
|
||
)
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="Order not found")
|
||
return dict(row)
|
||
|
||
# --- Web Pages ---
|
||
|
||
@app.get("/", response_class=HTMLResponse)
|
||
async def shop_home(request: Request):
|
||
"""Shop homepage"""
|
||
async with pool.acquire() as conn:
|
||
products = await conn.fetch("SELECT * FROM shop_products WHERE is_active = true ORDER BY category, name")
|
||
return templates.TemplateResponse("shop.html", {
|
||
"request": request,
|
||
"products": [dict(r) for r in products],
|
||
"categories": list(set(p["category"] for p in products))
|
||
})
|
||
|
||
@app.get("/product/{product_id}", response_class=HTMLResponse)
|
||
async def product_page(product_id: int, request: Request):
|
||
"""Product detail page"""
|
||
async with pool.acquire() as conn:
|
||
product = await conn.fetchrow("SELECT * FROM shop_products WHERE id = $1 AND is_active = true", product_id)
|
||
if not product:
|
||
raise HTTPException(status_code=404, detail="Product not found")
|
||
return templates.TemplateResponse("product.html", {
|
||
"request": request,
|
||
"product": dict(product)
|
||
})
|
||
|
||
@app.get("/checkout", response_class=HTMLResponse)
|
||
async def checkout_page(request: Request):
|
||
"""Checkout page with Stripe payment"""
|
||
return templates.TemplateResponse("checkout.html", {"request": request})
|
||
|
||
@app.get("/verify/{contributor_id}", response_class=HTMLResponse)
|
||
async def verify_page(contributor_id: str, request: Request):
|
||
"""Public verification page for Field Contributor ID cards"""
|
||
async with pool.acquire() as conn:
|
||
# Look up user by contributor ID
|
||
user = await conn.fetchrow("""
|
||
SELECT user_id, display_name, created_at, identity_verified
|
||
FROM zoomer_profiles
|
||
WHERE contributor_id = $1
|
||
""", contributor_id)
|
||
|
||
if not user:
|
||
return templates.TemplateResponse("verify.html", {
|
||
"request": request,
|
||
"contributor_id": contributor_id,
|
||
"contributor": None
|
||
})
|
||
|
||
# Get stats
|
||
stats = await get_user_stats(user["user_id"])
|
||
|
||
# Check if still valid
|
||
is_valid = (
|
||
stats["completed_missions"] >= VERIFIED_MISSION_THRESHOLD and
|
||
stats["approval_rate"] >= VERIFIED_APPROVAL_RATE and
|
||
user["identity_verified"] and
|
||
stats["serious_violations"] == 0
|
||
)
|
||
|
||
# Get initials from name
|
||
name = user["display_name"] or "Contributor"
|
||
initials = "".join(word[0].upper() for word in name.split()[:2])
|
||
|
||
contributor = {
|
||
"valid": is_valid,
|
||
"id": contributor_id,
|
||
"name": name,
|
||
"initials": initials,
|
||
"completed_missions": stats["completed_missions"],
|
||
"approval_rate": stats["approval_rate"],
|
||
"member_since": user["created_at"].strftime("%Y") if user["created_at"] else "N/A"
|
||
}
|
||
|
||
return templates.TemplateResponse("verify.html", {
|
||
"request": request,
|
||
"contributor_id": contributor_id,
|
||
"contributor": contributor
|
||
})
|
||
|
||
# --- Stripe Payment Endpoints ---
|
||
|
||
@app.post("/api/payment/create-intent", response_model=PaymentIntentResponse)
|
||
async def create_payment_intent(req: PaymentIntentRequest, request: Request):
|
||
"""Create Stripe PaymentIntent for an order"""
|
||
if not STRIPE_SECRET_KEY:
|
||
raise HTTPException(status_code=500, detail="Stripe not configured")
|
||
|
||
auth_header = request.headers.get("authorization", "")
|
||
token = auth_header.replace("Bearer ", "") if auth_header.startswith("Bearer ") else None
|
||
user = await get_current_user(token)
|
||
user_id = user.get("sub") or user.get("user_id")
|
||
|
||
# Get order
|
||
async with pool.acquire() as conn:
|
||
order = await conn.fetchrow(
|
||
"SELECT * FROM shop_orders WHERE id = $1 AND user_id = $2 AND status = 'pending'",
|
||
req.order_id, user_id
|
||
)
|
||
if not order:
|
||
raise HTTPException(status_code=404, detail="Order not found or already paid")
|
||
|
||
# Create PaymentIntent
|
||
try:
|
||
intent = stripe.PaymentIntent.create(
|
||
amount=order["total_amount"],
|
||
currency="sek",
|
||
metadata={
|
||
"order_id": str(req.order_id),
|
||
"user_id": user_id,
|
||
},
|
||
automatic_payment_methods={"enabled": True},
|
||
)
|
||
|
||
# Save intent ID to order
|
||
async with pool.acquire() as conn:
|
||
await conn.execute(
|
||
"UPDATE shop_orders SET stripe_payment_intent_id = $1 WHERE id = $2",
|
||
intent.id, req.order_id
|
||
)
|
||
|
||
return {
|
||
"client_secret": intent.client_secret,
|
||
"publishable_key": os.getenv("STRIPE_PUBLISHABLE_KEY", "pk_test_...")
|
||
}
|
||
except stripe.error.StripeError as e:
|
||
raise HTTPException(status_code=400, detail=str(e))
|
||
|
||
@app.post("/api/payment/webhook")
|
||
async def stripe_webhook(request: Request):
|
||
"""Handle Stripe webhooks"""
|
||
payload = await request.body()
|
||
sig_header = request.headers.get("stripe-signature")
|
||
|
||
if not STRIPE_WEBHOOK_SECRET:
|
||
raise HTTPException(status_code=500, detail="Webhook secret not configured")
|
||
|
||
try:
|
||
event = stripe.Webhook.construct_event(
|
||
payload, sig_header, STRIPE_WEBHOOK_SECRET
|
||
)
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="Invalid payload")
|
||
except stripe.error.SignatureVerificationError:
|
||
raise HTTPException(status_code=400, detail="Invalid signature")
|
||
|
||
# Handle payment success
|
||
if event["type"] == "payment_intent.succeeded":
|
||
intent = event["data"]["object"]
|
||
order_id = int(intent["metadata"]["order_id"])
|
||
|
||
async with pool.acquire() as conn:
|
||
await conn.execute(
|
||
"UPDATE shop_orders SET status = 'paid', updated_at = NOW() WHERE id = $1",
|
||
order_id
|
||
)
|
||
|
||
elif event["type"] == "payment_intent.payment_failed":
|
||
intent = event["data"]["object"]
|
||
order_id = int(intent["metadata"]["order_id"])
|
||
|
||
async with pool.acquire() as conn:
|
||
await conn.execute(
|
||
"UPDATE shop_orders SET status = 'payment_failed', updated_at = NOW() WHERE id = $1",
|
||
order_id
|
||
)
|
||
|
||
return {"status": "ok"}
|
||
|
||
# --- Error Handlers ---
|
||
|
||
@app.exception_handler(Exception)
|
||
async def global_exception_handler(request: Request, exc: Exception):
|
||
return JSONResponse(
|
||
status_code=500,
|
||
content={"error": "Internal server error", "detail": str(exc)}
|
||
)
|
||
|
||
# --- QR Code Endpoint ---
|
||
|
||
@app.get("/api/verify/qrcode/{contributor_id}")
|
||
async def get_qrcode(contributor_id: str):
|
||
"""Generate QR code for contributor verification"""
|
||
verify_url = f"https://shop.quixzoom.com/verify/{contributor_id}"
|
||
|
||
qr = qrcode.QRCode(
|
||
version=1,
|
||
error_correction=qrcode.constants.ERROR_CORRECT_H,
|
||
box_size=10,
|
||
border=4,
|
||
)
|
||
qr.add_data(verify_url)
|
||
qr.make(fit=True)
|
||
|
||
img = qr.make_image(fill_color="black", back_color="white")
|
||
|
||
# Convert to base64
|
||
buffer = io.BytesIO()
|
||
img.save(buffer, format='PNG')
|
||
img_str = base64.b64encode(buffer.getvalue()).decode()
|
||
|
||
return {
|
||
"qr_code": f"data:image/png;base64,{img_str}",
|
||
"verify_url": verify_url
|
||
}
|
||
|
||
# --- Error Handlers ---
|
||
|
||
@app.exception_handler(Exception)
|
||
async def global_exception_handler(request: Request, exc: Exception):
|
||
return JSONResponse(
|
||
status_code=500,
|
||
content={"error": "Internal server error", "detail": str(exc)}
|
||
)
|
||
|
||
if __name__ == "__main__":
|
||
import uvicorn
|
||
port = int(os.getenv('PORT', 8080))
|
||
host = os.getenv('HOST', '0.0.0.0')
|
||
uvicorn.run(app, host=host, port=port)
|