add promocodes
This commit is contained in:
145
app/services/promo.py
Normal file
145
app/services/promo.py
Normal file
@ -0,0 +1,145 @@
|
||||
from datetime import datetime
|
||||
from fastapi import HTTPException
|
||||
from bson import ObjectId
|
||||
from pymongo import ReturnDocument
|
||||
from app.db.database import db
|
||||
from app.services.coins import CoinsService
|
||||
|
||||
promo_codes = db["promo_codes"]
|
||||
promo_redemptions = db["promo_redemptions"]
|
||||
|
||||
class PromoService:
|
||||
def __init__(self):
|
||||
self.coins = CoinsService()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_code(code: str) -> str:
|
||||
return code.strip().upper()
|
||||
|
||||
async def ensure_indexes(self):
|
||||
# уникальность самого кода
|
||||
await promo_codes.create_index("code", unique=True)
|
||||
# одноразовость на пользователя
|
||||
await promo_redemptions.create_index([("code", 1), ("username", 1)], unique=True)
|
||||
await promo_redemptions.create_index("redeemed_at")
|
||||
|
||||
async def create(self, payload):
|
||||
now = datetime.utcnow()
|
||||
doc = {
|
||||
"code": self._normalize_code(payload.code),
|
||||
"reward_coins": payload.reward_coins,
|
||||
"max_uses": payload.max_uses,
|
||||
"uses_count": 0,
|
||||
"is_active": payload.is_active,
|
||||
"starts_at": payload.starts_at,
|
||||
"ends_at": payload.ends_at,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
try:
|
||||
r = await promo_codes.insert_one(doc)
|
||||
except Exception:
|
||||
# можно точнее ловить DuplicateKeyError
|
||||
raise HTTPException(status_code=409, detail="Promo code already exists")
|
||||
doc["_id"] = r.inserted_id
|
||||
return doc
|
||||
|
||||
async def list(self, limit: int = 50, skip: int = 0):
|
||||
cursor = promo_codes.find({}).sort("created_at", -1).skip(skip).limit(limit)
|
||||
return await cursor.to_list(length=limit)
|
||||
|
||||
async def update(self, promo_id: str, payload):
|
||||
try:
|
||||
oid = ObjectId(promo_id)
|
||||
except:
|
||||
raise HTTPException(status_code=400, detail="Invalid promo id")
|
||||
|
||||
update_data = {k: v for k, v in payload.dict(exclude_unset=True).items()}
|
||||
if not update_data:
|
||||
doc = await promo_codes.find_one({"_id": oid})
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Promo not found")
|
||||
return doc
|
||||
|
||||
update_data["updated_at"] = datetime.utcnow()
|
||||
|
||||
doc = await promo_codes.find_one_and_update(
|
||||
{"_id": oid},
|
||||
{"$set": update_data},
|
||||
return_document=ReturnDocument.AFTER
|
||||
)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Promo not found")
|
||||
return doc
|
||||
|
||||
async def delete(self, promo_id: str):
|
||||
try:
|
||||
oid = ObjectId(promo_id)
|
||||
except:
|
||||
raise HTTPException(status_code=400, detail="Invalid promo id")
|
||||
|
||||
r = await promo_codes.delete_one({"_id": oid})
|
||||
if r.deleted_count == 0:
|
||||
raise HTTPException(status_code=404, detail="Promo not found")
|
||||
return {"status": "success"}
|
||||
|
||||
async def redeem(self, username: str, code: str):
|
||||
username = username.strip()
|
||||
code = self._normalize_code(code)
|
||||
now = datetime.utcnow()
|
||||
|
||||
# 1) Сначала фиксируем, что этот пользователь пытается активировать этот код.
|
||||
# Уникальный индекс (code, username) гарантирует одноразовость.
|
||||
try:
|
||||
await promo_redemptions.insert_one({
|
||||
"code": code,
|
||||
"username": username,
|
||||
"redeemed_at": now,
|
||||
})
|
||||
except Exception:
|
||||
# DuplicateKey -> уже активировал
|
||||
raise HTTPException(status_code=409, detail="Promo code already redeemed by this user")
|
||||
|
||||
# 2) Затем пытаемся “забрать” 1 использование у промокода атомарно.
|
||||
# Если max_uses = None -> ограничение не проверяем.
|
||||
promo = await promo_codes.find_one({"code": code})
|
||||
if not promo:
|
||||
# откатываем redemption запись
|
||||
await promo_redemptions.delete_one({"code": code, "username": username})
|
||||
raise HTTPException(status_code=404, detail="Promo code not found")
|
||||
|
||||
if not promo.get("is_active", True):
|
||||
await promo_redemptions.delete_one({"code": code, "username": username})
|
||||
raise HTTPException(status_code=400, detail="Promo code is inactive")
|
||||
|
||||
starts_at = promo.get("starts_at")
|
||||
ends_at = promo.get("ends_at")
|
||||
if starts_at and now < starts_at:
|
||||
await promo_redemptions.delete_one({"code": code, "username": username})
|
||||
raise HTTPException(status_code=400, detail="Promo code is not started yet")
|
||||
if ends_at and now > ends_at:
|
||||
await promo_redemptions.delete_one({"code": code, "username": username})
|
||||
raise HTTPException(status_code=400, detail="Promo code expired")
|
||||
|
||||
query = {"code": code}
|
||||
if promo.get("max_uses") is not None:
|
||||
query["uses_count"] = {"$lt": promo["max_uses"]}
|
||||
|
||||
updated = await promo_codes.find_one_and_update(
|
||||
query,
|
||||
{"$inc": {"uses_count": 1}, "$set": {"updated_at": now}},
|
||||
return_document=ReturnDocument.AFTER
|
||||
)
|
||||
|
||||
if not updated:
|
||||
# лимит исчерпан — откатываем redemption
|
||||
await promo_redemptions.delete_one({"code": code, "username": username})
|
||||
raise HTTPException(status_code=409, detail="Promo code usage limit reached")
|
||||
|
||||
# 3) Начисляем монеты
|
||||
new_balance = await self.coins.increase_balance(username, promo["reward_coins"])
|
||||
return {
|
||||
"code": code,
|
||||
"reward_coins": promo["reward_coins"],
|
||||
"new_balance": new_balance
|
||||
}
|
||||
Reference in New Issue
Block a user