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: @staticmethod def _to_json(doc: dict | None): if not doc: return doc doc = dict(doc) if "_id" in doc: doc["_id"] = str(doc["_id"]) return doc 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 self._to_json(doc) async def list(self, limit: int = 50, skip: int = 0): cursor = promo_codes.find({}).sort("created_at", -1).skip(skip).limit(limit) items = await cursor.to_list(length=limit) return [self._to_json(x) for x in items] 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 self._to_json(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 self._to_json(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 }