mitai-jinkendo/backend/routers/coupons.py
Lars ae9743d6ed
All checks were successful
Deploy Development / deploy (push) Successful in 56s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 12s
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>
2026-03-19 13:07:09 +01:00

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
}