feat: add coupon management and redemption
New router: routers/coupons.py
Admin endpoints:
- GET /api/coupons - List all coupons with stats
- POST /api/coupons - Create new coupon
- PUT /api/coupons/{id} - Update coupon
- DELETE /api/coupons/{id} - Soft-delete (set active=false)
- GET /api/coupons/{id}/redemptions - Redemption history
User endpoints:
- POST /api/coupons/redeem - Redeem coupon code
Features:
- Three coupon types: single_use, period, wellpass
- Wellpass logic: Pauses existing personal grants, resumes after expiry
- Max redemptions limit (NULL = unlimited)
- Validity period checks
- Activity logging
- Duplicate redemption prevention
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ae47652d0c
commit
ae9743d6ed
|
|
@ -17,7 +17,7 @@ from db import init_db
|
|||
# Import routers
|
||||
from routers import auth, profiles, weight, circumference, caliper
|
||||
from routers import activity, nutrition, photos, insights, prompts
|
||||
from routers import admin, stats, exportdata, importdata, subscription
|
||||
from routers import admin, stats, exportdata, importdata, subscription, coupons
|
||||
|
||||
# ── App Configuration ─────────────────────────────────────────────────────────
|
||||
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
||||
|
|
@ -74,6 +74,7 @@ app.include_router(stats.router) # /api/stats
|
|||
app.include_router(exportdata.router) # /api/export/*
|
||||
app.include_router(importdata.router) # /api/import/*
|
||||
app.include_router(subscription.router) # /api/subscription/*
|
||||
app.include_router(coupons.router) # /api/coupons/*
|
||||
|
||||
# ── Health Check ──────────────────────────────────────────────────────────────
|
||||
@app.get("/")
|
||||
|
|
|
|||
282
backend/routers/coupons.py
Normal file
282
backend/routers/coupons.py
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
"""
|
||||
Coupon Management Endpoints for Mitai Jinkendo
|
||||
|
||||
Handles coupon CRUD (admin) and redemption (users).
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth, require_admin
|
||||
|
||||
router = APIRouter(prefix="/api/coupons", tags=["coupons"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_coupons(session: dict = Depends(require_admin)):
|
||||
"""Admin: List all coupons with redemption stats."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("""
|
||||
SELECT
|
||||
c.*,
|
||||
t.name as tier_name,
|
||||
(SELECT COUNT(*) FROM coupon_redemptions WHERE coupon_id = c.id) as redemptions
|
||||
FROM coupons c
|
||||
LEFT JOIN tiers t ON t.id = c.tier_id
|
||||
ORDER BY c.created DESC
|
||||
""")
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_coupon(data: dict, session: dict = Depends(require_admin)):
|
||||
"""
|
||||
Admin: Create new coupon.
|
||||
|
||||
Required fields:
|
||||
- code: Unique coupon code
|
||||
- type: 'single_use', 'period', or 'wellpass'
|
||||
- tier_id: Target tier
|
||||
- duration_days: For period/wellpass coupons
|
||||
|
||||
Optional fields:
|
||||
- max_redemptions: NULL = unlimited
|
||||
- valid_from, valid_until: Validity period
|
||||
- description: Internal note
|
||||
"""
|
||||
code = data.get('code', '').strip().upper()
|
||||
coupon_type = data.get('type')
|
||||
tier_id = data.get('tier_id')
|
||||
duration_days = data.get('duration_days')
|
||||
max_redemptions = data.get('max_redemptions')
|
||||
valid_from = data.get('valid_from')
|
||||
valid_until = data.get('valid_until')
|
||||
description = data.get('description', '')
|
||||
|
||||
if not code:
|
||||
raise HTTPException(400, "Coupon-Code fehlt")
|
||||
if coupon_type not in ['single_use', 'period', 'wellpass']:
|
||||
raise HTTPException(400, "Ungültiger Coupon-Typ")
|
||||
if not tier_id:
|
||||
raise HTTPException(400, "Tier fehlt")
|
||||
if coupon_type in ['period', 'wellpass'] and not duration_days:
|
||||
raise HTTPException(400, "duration_days fehlt für period/wellpass Coupons")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Check if code already exists
|
||||
cur.execute("SELECT id FROM coupons WHERE code = %s", (code,))
|
||||
if cur.fetchone():
|
||||
raise HTTPException(400, f"Coupon-Code '{code}' existiert bereits")
|
||||
|
||||
# Create coupon
|
||||
cur.execute("""
|
||||
INSERT INTO coupons (
|
||||
code, type, tier_id, duration_days, max_redemptions,
|
||||
valid_from, valid_until, description, created_by
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
code, coupon_type, tier_id, duration_days, max_redemptions,
|
||||
valid_from, valid_until, description, session['profile_id']
|
||||
))
|
||||
|
||||
coupon_id = cur.fetchone()['id']
|
||||
conn.commit()
|
||||
|
||||
return {"ok": True, "id": coupon_id, "code": code}
|
||||
|
||||
|
||||
@router.put("/{coupon_id}")
|
||||
def update_coupon(coupon_id: str, data: dict, session: dict = Depends(require_admin)):
|
||||
"""Admin: Update coupon."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
updates = []
|
||||
values = []
|
||||
|
||||
if 'active' in data:
|
||||
updates.append('active = %s')
|
||||
values.append(data['active'])
|
||||
if 'max_redemptions' in data:
|
||||
updates.append('max_redemptions = %s')
|
||||
values.append(data['max_redemptions'])
|
||||
if 'valid_until' in data:
|
||||
updates.append('valid_until = %s')
|
||||
values.append(data['valid_until'])
|
||||
if 'description' in data:
|
||||
updates.append('description = %s')
|
||||
values.append(data['description'])
|
||||
|
||||
if not updates:
|
||||
return {"ok": True}
|
||||
|
||||
updates.append('updated = CURRENT_TIMESTAMP')
|
||||
values.append(coupon_id)
|
||||
|
||||
cur.execute(
|
||||
f"UPDATE coupons SET {', '.join(updates)} WHERE id = %s",
|
||||
values
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/{coupon_id}")
|
||||
def delete_coupon(coupon_id: str, session: dict = Depends(require_admin)):
|
||||
"""Admin: Delete coupon (soft-delete: set active=false)."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("UPDATE coupons SET active = false WHERE id = %s", (coupon_id,))
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/{coupon_id}/redemptions")
|
||||
def get_coupon_redemptions(coupon_id: str, session: dict = Depends(require_admin)):
|
||||
"""Admin: Get all redemptions for a coupon."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("""
|
||||
SELECT
|
||||
cr.id,
|
||||
cr.redeemed_at,
|
||||
p.name as profile_name,
|
||||
p.email as profile_email,
|
||||
ag.valid_from,
|
||||
ag.valid_until,
|
||||
ag.is_active
|
||||
FROM coupon_redemptions cr
|
||||
JOIN profiles p ON p.id = cr.profile_id
|
||||
LEFT JOIN access_grants ag ON ag.id = cr.access_grant_id
|
||||
WHERE cr.coupon_id = %s
|
||||
ORDER BY cr.redeemed_at DESC
|
||||
""", (coupon_id,))
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
@router.post("/redeem")
|
||||
def redeem_coupon(data: dict, session: dict = Depends(require_auth)):
|
||||
"""
|
||||
User: Redeem a coupon code.
|
||||
|
||||
Creates an access_grant and handles Wellpass pause/resume logic.
|
||||
"""
|
||||
code = data.get('code', '').strip().upper()
|
||||
if not code:
|
||||
raise HTTPException(400, "Coupon-Code fehlt")
|
||||
|
||||
profile_id = session['profile_id']
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Get coupon
|
||||
cur.execute("""
|
||||
SELECT * FROM coupons
|
||||
WHERE code = %s AND active = true
|
||||
""", (code,))
|
||||
coupon = cur.fetchone()
|
||||
|
||||
if not coupon:
|
||||
raise HTTPException(404, "Ungültiger Coupon-Code")
|
||||
|
||||
# Check validity period
|
||||
now = datetime.now()
|
||||
if coupon['valid_from'] and now < coupon['valid_from']:
|
||||
raise HTTPException(400, "Coupon noch nicht gültig")
|
||||
if coupon['valid_until'] and now > coupon['valid_until']:
|
||||
raise HTTPException(400, "Coupon abgelaufen")
|
||||
|
||||
# Check max redemptions
|
||||
if coupon['max_redemptions'] is not None:
|
||||
if coupon['redemption_count'] >= coupon['max_redemptions']:
|
||||
raise HTTPException(400, "Coupon bereits vollständig eingelöst")
|
||||
|
||||
# Check if user already redeemed this coupon
|
||||
cur.execute("""
|
||||
SELECT id FROM coupon_redemptions
|
||||
WHERE coupon_id = %s AND profile_id = %s
|
||||
""", (coupon['id'], profile_id))
|
||||
if cur.fetchone():
|
||||
raise HTTPException(400, "Du hast diesen Coupon bereits eingelöst")
|
||||
|
||||
# Create access grant
|
||||
valid_from = now
|
||||
valid_until = now + timedelta(days=coupon['duration_days']) if coupon['duration_days'] else None
|
||||
|
||||
# Wellpass logic: Pause existing personal grants
|
||||
if coupon['type'] == 'wellpass':
|
||||
cur.execute("""
|
||||
SELECT id, valid_until
|
||||
FROM access_grants
|
||||
WHERE profile_id = %s
|
||||
AND is_active = true
|
||||
AND granted_by != 'wellpass'
|
||||
AND valid_until > CURRENT_TIMESTAMP
|
||||
""", (profile_id,))
|
||||
active_grants = cur.fetchall()
|
||||
|
||||
for grant in active_grants:
|
||||
# Calculate remaining days
|
||||
remaining = (grant['valid_until'] - now).days
|
||||
# Pause grant
|
||||
cur.execute("""
|
||||
UPDATE access_grants
|
||||
SET is_active = false,
|
||||
paused_at = CURRENT_TIMESTAMP,
|
||||
remaining_days = %s
|
||||
WHERE id = %s
|
||||
""", (remaining, grant['id']))
|
||||
|
||||
# Insert access grant
|
||||
cur.execute("""
|
||||
INSERT INTO access_grants (
|
||||
profile_id, tier_id, granted_by, coupon_id,
|
||||
valid_from, valid_until, is_active
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, true)
|
||||
RETURNING id
|
||||
""", (
|
||||
profile_id, coupon['tier_id'],
|
||||
coupon['type'], coupon['id'],
|
||||
valid_from, valid_until
|
||||
))
|
||||
grant_id = cur.fetchone()['id']
|
||||
|
||||
# Record redemption
|
||||
cur.execute("""
|
||||
INSERT INTO coupon_redemptions (coupon_id, profile_id, access_grant_id)
|
||||
VALUES (%s, %s, %s)
|
||||
""", (coupon['id'], profile_id, grant_id))
|
||||
|
||||
# Increment coupon redemption count
|
||||
cur.execute("""
|
||||
UPDATE coupons
|
||||
SET redemption_count = redemption_count + 1
|
||||
WHERE id = %s
|
||||
""", (coupon['id'],))
|
||||
|
||||
# Log activity
|
||||
cur.execute("""
|
||||
INSERT INTO user_activity_log (profile_id, action, details)
|
||||
VALUES (%s, 'coupon_redeemed', %s)
|
||||
""", (
|
||||
profile_id,
|
||||
f'{{"coupon_code": "{code}", "tier": "{coupon["tier_id"]}", "duration_days": {coupon["duration_days"]}}}'
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"message": f"Coupon erfolgreich eingelöst: {coupon['tier_id']} für {coupon['duration_days']} Tage",
|
||||
"grant_id": grant_id,
|
||||
"valid_until": valid_until.isoformat() if valid_until else None
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user