From ae9743d6ed63c0b10e5c104b559abfcb22ed13b8 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 19 Mar 2026 13:07:09 +0100 Subject: [PATCH] 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 --- backend/main.py | 3 +- backend/routers/coupons.py | 282 +++++++++++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 backend/routers/coupons.py diff --git a/backend/main.py b/backend/main.py index 90c112b..37b06f4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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("/") diff --git a/backend/routers/coupons.py b/backend/routers/coupons.py new file mode 100644 index 0000000..c41a576 --- /dev/null +++ b/backend/routers/coupons.py @@ -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 + }