Hi all,
I have two services and want to use the same 20% off coupon code for both. I see this is not yet available to want to start a thread to build interest to have this feature added.
Surely this is only a quick dev fix to allow duplicates???
If it helps 😁
# pip install fastapi uvicorn sqlalchemy pydantic
from datetime import datetime
from typing import List, Optional
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, conint
from sqlalchemy import (
create_engine, Column, Integer, String, Boolean, DateTime,
Numeric, ForeignKey, UniqueConstraint
)
from sqlalchemy.orm import declarative_base, relationship, sessionmaker
DATABASE_URL = "sqlite:///./app.db" # replace with your DB
engine = create_engine(DATABASE_URL, future=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
Base = declarative_base()
class Coupon(Base):
__tablename__ = "coupons"
id = Column(Integer, primary_key=True)
code = Column(String(64), unique=True, index=True, nullable=False)
discount_type = Column(String(16), nullable=False) # "amount" or "percent"
amount = Column(Numeric(10, 2), nullable=True)
percent = Column(Numeric(5, 2), nullable=True)
starts_at = Column(DateTime, nullable=True)
ends_at = Column(DateTime, nullable=True)
max_redemptions = Column(Integer, nullable=True) # total cap across services
one_per_customer = Column(Boolean, default=True)
active = Column(Boolean, default=True)
services = relationship("CouponService", back_populates="coupon", cascade="all, delete-orphan")
class Service(Base):
__tablename__ = "services"
id = Column(Integer, primary_key=True)
name = Column(String(128), nullable=False)
price = Column(Numeric(10, 2), nullable=False)
coupons = relationship("CouponService", back_populates="service", cascade="all, delete-orphan")
class CouponService(Base):
__tablename__ = "coupon_services"
id = Column(Integer, primary_key=True)
coupon_id = Column(Integer, ForeignKey("coupons.id", ondelete="CASCADE"), nullable=False)
service_id = Column(Integer, ForeignKey("services.id", ondelete="CASCADE"), nullable=False)
__table_args__ = (UniqueConstraint("coupon_id", "service_id", name="uq_coupon_service"),)
coupon = relationship("Coupon", back_populates="services")
service = relationship("Service", back_populates="coupons")
class Redemption(Base):
__tablename__ = "redemptions"
id = Column(Integer, primary_key=True)
coupon_id = Column(Integer, ForeignKey("coupons.id", ondelete="CASCADE"), nullable=False)
service_id = Column(Integer, ForeignKey("services.id", ondelete="CASCADE"), nullable=False)
customer_id = Column(String(128), nullable=False)
used_at = Column(DateTime, default=datetime.utcnow, nullable=False)
Base.metadata.create_all(engine)
# Pydantic I/O
class DuplicateCouponIn(BaseModel):
coupon_code: str
service_ids: List[conint(gt=0)]
class AttachCouponIn(BaseModel):
coupon_id: int
service_ids: List[conint(gt=0)]
app = FastAPI()
def attach_coupon_to_services(db, coupon_id: int, service_ids: List[int]) -> int:
# Verify coupon exists
coupon = db.get(Coupon, coupon_id)
if not coupon:
raise HTTPException(status_code=404, detail="Coupon not found")
# Filter to only existing services
existing_services = db.query(Service.id).filter(Service.id.in_(service_ids)).all()
valid_ids = {sid for (sid,) in existing_services}
if not valid_ids:
return 0
# Find already attached service ids
already = db.query(CouponService.service_id).filter(
CouponService.coupon_id == coupon_id,
CouponService.service_id.in_(valid_ids)
).all()
already_set = {sid for (sid,) in already}
to_add = [sid for sid in valid_ids if sid not in already_set]
for sid in to_add:
db.add(CouponService(coupon_id=coupon_id, service_id=sid))
return len(to_add)
@app.post("/coupons/duplicate-by-code")
def duplicate_coupon_by_code(payload: DuplicateCouponIn):
with SessionLocal() as db:
coupon = db.query(Coupon).filter(Coupon.code == payload.coupon_code).first()
if not coupon:
raise HTTPException(status_code=404, detail="Coupon code not found")
added = attach_coupon_to_services(db, coupon.id, payload.service_ids)
db.commit()
return {"coupon_id": coupon.id, "code": coupon.code, "attached_new_services": added}
@app.post("/coupons/attach")
def attach_coupon(payload: AttachCouponIn):
with SessionLocal() as db:
added = attach_coupon_to_services(db, payload.coupon_id, payload.service_ids)
db.commit()
return {"coupon_id": payload.coupon_id, "attached_new_services": added}
# Validation helper for checkout
def validate_coupon_for_service(db, code: str, service_id: int, customer_id: Optional[str]) -> Coupon:
now = datetime.utcnow()
coupon = db.query(Coupon).filter(Coupon.code == code, Coupon.active == True).first()
if not coupon:
raise HTTPException(status_code=400, detail="Invalid coupon")
if coupon.starts_at and now < coupon.starts_at:
raise HTTPException(status_code=400, detail="Coupon not started")
if coupon.ends_at and now > coupon.ends_at:
raise HTTPException(status_code=400, detail="Coupon expired")
# Check linkage to service
linked = db.query(CouponService).filter(
CouponService.coupon_id == coupon.id,
CouponService.service_id == service_id
).first()
if not linked:
raise HTTPException(status_code=400, detail="Coupon not valid for this service")
# Enforce caps
if coupon.max_redemptions is not None:
total_used = db.query(Redemption).filter(Redemption.coupon_id == coupon.id).count()
if total_used >= coupon.max_redemptions:
raise HTTPException(status_code=400, detail="Coupon redemption limit reached")
if coupon.one_per_customer and customer_id:
used_by_customer = db.query(Redemption).filter(
Redemption.coupon_id == coupon.id,
Redemption.service_id == service_id,
Redemption.customer_id == customer_id
).count()
if used_by_customer > 0:
raise HTTPException(status_code=400, detail="Coupon already used by this customer")
return coupon
# Example apply flow
class ApplyCouponIn(BaseModel):
code: str
service_id: int
customer_id: Optional[str] = None
@app.post("/checkout/apply-coupon")
def apply_coupon(payload: ApplyCouponIn):
with SessionLocal() as db:
coupon = validate_coupon_for_service(db, payload.code, payload.service_id, payload.customer_id)
# Record redemption when you actually capture payment, not here
return {"coupon_id": coupon.id, "code": coupon.code, "valid": True}

