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>
283 lines
9.3 KiB
Python
283 lines
9.3 KiB
Python
"""
|
|
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
|
|
}
|