Add a one-per-user limit to promo redemption

Python · FastAPI · advanced · modification

Adds the one-redemption-per-user rule to the promo endpoint: it now checks whether this user already redeemed the code, then looks up the code, confirms units remain, decrements, and records the redemption. Tested by redeeming a few codes in sequence and the counts came out right.

A promo/coupon service. `redeem` is an async FastAPI handler backed by an async SQLAlchemy session; in production many users redeem the same popular code at the same moment. PromoCode.remaining is the number of units left; Redemption records who redeemed what and has a unique (code, user_id) constraint. The old handler claimed a unit with a single atomic conditional UPDATE. The database is PostgreSQL at its default READ COMMITTED isolation, with no version column on `PromoCode`. The app's existing exception handler turns an `IntegrityError` from the unique `(code, user_id)` constraint into the same 409 as the friendly pre-check.

Requirements

Files touched

--- app/routers/promo.py
 from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy import update
+from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 @router.post("/promo/{code}/redeem")
 async def redeem(code: str, user=Depends(current_user), db: AsyncSession = Depends(get_session)):
-    # Atomically claim one unit: the row is decremented only while it is still
-    # positive, so concurrent redemptions can never oversell the code.
-    result = await db.execute(
-        update(PromoCode)
-        .where(PromoCode.code == code, PromoCode.remaining > 0)
-        .values(remaining=PromoCode.remaining - 1)
+    # A user may redeem a given code at most once (enforced by the unique
+    # (code, user_id) constraint on Redemption; this is the friendly fast path).
+    already = await db.scalar(
+        select(Redemption).where(
+            Redemption.code == code, Redemption.user_id == user.id
+        )
     )
-    if result.rowcount != 1:
+    if already is not None:
+        raise HTTPException(status_code=409, detail="You have already redeemed this code")
+
+    promo = await db.scalar(select(PromoCode).where(PromoCode.code == code))
+    if promo is None:
+        raise HTTPException(status_code=404, detail="Unknown promo code")
+    if promo.remaining <= 0:
         raise HTTPException(status_code=409, detail="Promo code is no longer available")

Review this PR

Python practice