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
- redeem() lets a signed-in user claim one unit of a promo code. Each code has a hard cap: `remaining` must NEVER go below 0 and the number of successful redemptions must NEVER exceed the code's initial `remaining` — even under many concurrent requests (the handler is async and requests run truly concurrently against the DB).
- This PR adds the rule that a user may redeem a given code at most once. Redemption has a UNIQUE (code, user_id) constraint, so a duplicate per-user redemption is rejected by the database at insert — the pre-check is only a friendly fast path, not the guard.
- The `remaining` counter has NO such database constraint — it is a mutable count, so the handler itself is responsible for never overselling it under concurrency.
Files touched
- app/routers/promo.py
--- 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")