# app/modules/loyalty/routes/api/admin.py """ Loyalty module admin routes. Platform admin endpoints for: - Viewing all loyalty programs (merchant-based) - Merchant loyalty settings management - Platform-wide analytics """ import logging from fastapi import APIRouter, Depends, Path, Query from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app.api.deps import ( get_current_admin_api, get_current_super_admin_api, require_module_access, ) from app.core.database import get_db from app.modules.enums import FrontendType from app.modules.loyalty.schemas import ( CardDetailResponse, CardListResponse, CardResponse, MerchantSettingsResponse, MerchantSettingsUpdate, MerchantStatsResponse, PinDetailListResponse, PinDetailResponse, ProgramCreate, ProgramListResponse, ProgramResponse, ProgramStatsResponse, ProgramUpdate, TransactionListResponse, TransactionResponse, ) from app.modules.loyalty.services import card_service, pin_service, program_service from app.modules.loyalty.services.analytics_service import analytics_service from app.modules.tenancy.models import User # API-007 logger = logging.getLogger(__name__) # Admin router with module access control router = APIRouter( prefix="/loyalty", dependencies=[Depends(require_module_access("loyalty", FrontendType.ADMIN))], ) # ============================================================================= # Program Management # ============================================================================= @router.get("/programs", response_model=ProgramListResponse) def list_programs( skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), is_active: bool | None = Query(None), search: str | None = Query(None, description="Search by merchant name"), current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """List all loyalty programs (platform admin).""" programs, total = program_service.list_programs( db, skip=skip, limit=limit, is_active=is_active, search=search, ) program_responses = [] for program in programs: response = ProgramResponse.model_validate(program) response.is_stamps_enabled = program.is_stamps_enabled response.is_points_enabled = program.is_points_enabled response.display_name = program.display_name # Get aggregation stats from service list_stats = program_service.get_program_list_stats(db, program) response.merchant_name = list_stats["merchant_name"] response.total_cards = list_stats["total_cards"] response.active_cards = list_stats["active_cards"] response.total_points_issued = list_stats["total_points_issued"] response.total_points_redeemed = list_stats["total_points_redeemed"] program_responses.append(response) return ProgramListResponse(programs=program_responses, total=total) @router.get("/programs/{program_id}", response_model=ProgramResponse) def get_program( program_id: int, current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """Get a specific loyalty program.""" program = program_service.require_program(db, program_id) response = ProgramResponse.model_validate(program) response.is_stamps_enabled = program.is_stamps_enabled response.is_points_enabled = program.is_points_enabled response.display_name = program.display_name return response @router.get("/programs/{program_id}/stats", response_model=ProgramStatsResponse) def get_program_stats( program_id: int, current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """Get statistics for a loyalty program.""" stats = program_service.get_program_stats(db, program_id) return ProgramStatsResponse(**stats) @router.get("/merchants/{merchant_id}/program", response_model=ProgramResponse) def get_merchant_program( merchant_id: int = Path(..., gt=0), current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """Get a merchant's loyalty program (on behalf).""" program = program_service.require_program_by_merchant(db, merchant_id) response = ProgramResponse.model_validate(program) response.is_stamps_enabled = program.is_stamps_enabled response.is_points_enabled = program.is_points_enabled response.display_name = program.display_name return response @router.post( "/merchants/{merchant_id}/program", response_model=ProgramResponse, status_code=201 ) def create_program_for_merchant( data: ProgramCreate, merchant_id: int = Path(..., gt=0), current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """Create a loyalty program for a merchant (admin override).""" program = program_service.create_program(db, merchant_id, data) response = ProgramResponse.model_validate(program) response.is_stamps_enabled = program.is_stamps_enabled response.is_points_enabled = program.is_points_enabled response.display_name = program.display_name logger.info(f"Admin created loyalty program for merchant {merchant_id}") return response @router.patch("/programs/{program_id}", response_model=ProgramResponse) def update_program( data: ProgramUpdate, program_id: int = Path(..., gt=0), current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """Update a loyalty program (admin override).""" program = program_service.update_program(db, program_id, data) response = ProgramResponse.model_validate(program) response.is_stamps_enabled = program.is_stamps_enabled response.is_points_enabled = program.is_points_enabled response.display_name = program.display_name logger.info(f"Admin updated loyalty program {program_id}") return response @router.delete("/programs/{program_id}", status_code=204) def delete_program( program_id: int = Path(..., gt=0), current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """Delete a loyalty program (admin override).""" program_service.delete_program(db, program_id) logger.info( f"Admin {current_user.id} ({current_user.email}) deleted loyalty program {program_id}" ) @router.post("/programs/{program_id}/activate", response_model=ProgramResponse) def activate_program( program_id: int = Path(..., gt=0), current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """Activate a loyalty program.""" program = program_service.activate_program(db, program_id) response = ProgramResponse.model_validate(program) response.is_stamps_enabled = program.is_stamps_enabled response.is_points_enabled = program.is_points_enabled response.display_name = program.display_name logger.info(f"Admin activated loyalty program {program_id}") return response @router.post("/programs/{program_id}/deactivate", response_model=ProgramResponse) def deactivate_program( program_id: int = Path(..., gt=0), current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """Deactivate a loyalty program.""" program = program_service.deactivate_program(db, program_id) response = ProgramResponse.model_validate(program) response.is_stamps_enabled = program.is_stamps_enabled response.is_points_enabled = program.is_points_enabled response.display_name = program.display_name logger.info(f"Admin deactivated loyalty program {program_id}") return response # ============================================================================= # Merchant Management # ============================================================================= @router.get("/merchants/{merchant_id}/stats", response_model=MerchantStatsResponse) def get_merchant_stats( merchant_id: int = Path(..., gt=0), current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """Get merchant-wide loyalty statistics across all locations.""" stats = program_service.get_merchant_stats(db, merchant_id) return MerchantStatsResponse(**stats) @router.get("/merchants/{merchant_id}/settings", response_model=MerchantSettingsResponse) def get_merchant_settings( merchant_id: int = Path(..., gt=0), current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """Get merchant loyalty settings.""" settings = program_service.get_or_create_merchant_settings(db, merchant_id) return MerchantSettingsResponse.model_validate(settings) @router.patch("/merchants/{merchant_id}/settings", response_model=MerchantSettingsResponse) def update_merchant_settings( data: MerchantSettingsUpdate, merchant_id: int = Path(..., gt=0), current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """Update merchant loyalty settings (admin only).""" settings = program_service.get_or_create_merchant_settings(db, merchant_id) update_data = data.model_dump(exclude_unset=True) # Capture old values before overwrite for audit trail old_values = {field: getattr(settings, field) for field in update_data} for field, value in update_data.items(): setattr(settings, field, value) db.commit() db.refresh(settings) logger.info( f"Admin {current_user.id} ({current_user.email}) updated merchant {merchant_id} " f"loyalty settings: {list(update_data.keys())} (old: {old_values})" ) return MerchantSettingsResponse.model_validate(settings) # ============================================================================= # On-Behalf Card & Transaction Endpoints # ============================================================================= @router.get("/merchants/{merchant_id}/cards", response_model=CardListResponse) def list_merchant_cards( merchant_id: int = Path(..., gt=0), skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), is_active: bool | None = Query(None), search: str | None = Query(None, max_length=100), store_id: int | None = Query(None, description="Filter by enrolled store"), current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """List loyalty cards for a merchant (admin on-behalf).""" cards, total = card_service.list_cards( db, merchant_id, store_id=store_id, skip=skip, limit=limit, is_active=is_active, search=search, ) card_responses = [] for card in cards: program = card.program response = CardResponse( id=card.id, card_number=card.card_number, customer_id=card.customer_id, merchant_id=card.merchant_id, program_id=card.program_id, enrolled_at_store_id=card.enrolled_at_store_id, customer_name=card.customer.full_name if card.customer else None, customer_email=card.customer.email if card.customer else None, stamp_count=card.stamp_count, stamps_target=program.stamps_target if program else 0, stamps_until_reward=max(0, (program.stamps_target if program else 0) - card.stamp_count), total_stamps_earned=card.total_stamps_earned, stamps_redeemed=card.stamps_redeemed, points_balance=card.points_balance, total_points_earned=card.total_points_earned, points_redeemed=card.points_redeemed, is_active=card.is_active, created_at=card.created_at, has_google_wallet=bool(card.google_object_id), has_apple_wallet=bool(card.apple_serial_number), ) card_responses.append(response) return CardListResponse(cards=card_responses, total=total) @router.get("/merchants/{merchant_id}/cards/{card_id}", response_model=CardDetailResponse) def get_merchant_card( merchant_id: int = Path(..., gt=0), card_id: int = Path(..., gt=0), current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """Get detailed loyalty card info (admin on-behalf).""" card = card_service.get_card(db, card_id) if not card or card.merchant_id != merchant_id: from app.modules.loyalty.exceptions import LoyaltyCardNotFoundException raise LoyaltyCardNotFoundException(str(card_id)) program = card.program customer = card.customer return CardDetailResponse( id=card.id, card_number=card.card_number, customer_id=card.customer_id, merchant_id=card.merchant_id, program_id=card.program_id, enrolled_at_store_id=card.enrolled_at_store_id, customer_name=customer.full_name if customer else None, customer_email=customer.email if customer else None, merchant_name=card.merchant.name if card.merchant else None, qr_code_data=card.qr_code_data or card.card_number, program_name=program.display_name, program_type=program.loyalty_type, reward_description=program.stamps_reward_description, stamp_count=card.stamp_count, stamps_target=program.stamps_target, stamps_until_reward=max(0, program.stamps_target - card.stamp_count), total_stamps_earned=card.total_stamps_earned, stamps_redeemed=card.stamps_redeemed, points_balance=card.points_balance, total_points_earned=card.total_points_earned, points_redeemed=card.points_redeemed, is_active=card.is_active, created_at=card.created_at, last_stamp_at=card.last_stamp_at, last_points_at=card.last_points_at, last_redemption_at=card.last_redemption_at, last_activity_at=card.last_activity_at, has_google_wallet=bool(card.google_object_id), has_apple_wallet=bool(card.apple_serial_number), ) @router.get( "/merchants/{merchant_id}/cards/{card_id}/transactions", response_model=TransactionListResponse, ) def get_merchant_card_transactions( merchant_id: int = Path(..., gt=0), card_id: int = Path(..., gt=0), skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """Get transaction history for a card (admin on-behalf).""" card = card_service.get_card(db, card_id) if not card or card.merchant_id != merchant_id: from app.modules.loyalty.exceptions import LoyaltyCardNotFoundException raise LoyaltyCardNotFoundException(str(card_id)) transactions, total = card_service.get_card_transactions( db, card_id, skip=skip, limit=limit ) return TransactionListResponse( transactions=[TransactionResponse.model_validate(t) for t in transactions], total=total, ) @router.get("/merchants/{merchant_id}/transactions", response_model=TransactionListResponse) def list_merchant_transactions( merchant_id: int = Path(..., gt=0), skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), store_id: int | None = Query(None, description="Filter by store location"), current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """List recent transactions for a merchant (admin on-behalf).""" transactions, total = card_service.get_store_transactions( db, merchant_id, store_id=store_id, skip=skip, limit=limit ) tx_responses = [] for t in transactions: tx = TransactionResponse.model_validate(t) if t.card and t.card.customer: tx.customer_name = t.card.customer.full_name tx_responses.append(tx) return TransactionListResponse(transactions=tx_responses, total=total) @router.get("/merchants/{merchant_id}/pins", response_model=PinDetailListResponse) def list_merchant_pins( merchant_id: int = Path(..., gt=0), store_id: int | None = Query(None, description="Filter by store location"), is_active: bool | None = Query(None), current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """List staff PINs for a merchant (admin read-only).""" pins = pin_service.list_pins_for_merchant( db, merchant_id, store_id=store_id, is_active=is_active ) pin_responses = [PinDetailResponse.model_validate(pin) for pin in pins] return PinDetailListResponse(pins=pin_responses, total=len(pin_responses)) @router.get("/merchants/{merchant_id}/locations") def list_merchant_locations( merchant_id: int = Path(..., gt=0), current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """Get active store locations for a merchant.""" locations = program_service.get_merchant_locations(db, merchant_id) return [ { "id": store.id, "name": store.name, "code": store.store_code, } for store in locations ] # ============================================================================= # Platform Stats # ============================================================================= @router.get("/stats") def get_platform_stats( current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """Get platform-wide loyalty statistics.""" return program_service.get_platform_stats(db) # ============================================================================= # Advanced Analytics # ============================================================================= @router.get("/merchants/{merchant_id}/analytics/cohorts") def get_cohort_retention( merchant_id: int = Path(..., gt=0), months_back: int = Query(6, ge=1, le=24), current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """Cohort retention matrix for a merchant's loyalty program.""" return analytics_service.get_cohort_retention( db, merchant_id, months_back=months_back ) @router.get("/merchants/{merchant_id}/analytics/churn") def get_at_risk_cards( merchant_id: int = Path(..., gt=0), limit: int = Query(50, ge=1, le=200), current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """Cards at risk of churn for a merchant.""" return analytics_service.get_at_risk_cards( db, merchant_id, limit=limit ) @router.get("/merchants/{merchant_id}/analytics/revenue") def get_revenue_attribution( merchant_id: int = Path(..., gt=0), months_back: int = Query(6, ge=1, le=24), current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """Revenue attribution from loyalty transactions.""" return analytics_service.get_revenue_attribution( db, merchant_id, months_back=months_back ) # ============================================================================= # Wallet Integration Status # ============================================================================= @router.get("/wallet-status") def get_wallet_status( current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """Get wallet integration status for the platform.""" return program_service.get_wallet_integration_status(db) # ============================================================================= # Wallet Debug (super admin only) # ============================================================================= @router.get("/debug/config") # noqa: API001 def debug_wallet_config( current_user: User = Depends(get_current_super_admin_api), db: Session = Depends(get_db), ): """Validate Google Wallet configuration (super admin only).""" from app.modules.loyalty.config import config as loyalty_config from app.modules.loyalty.services.google_wallet_service import google_wallet_service result = google_wallet_service.validate_config() result["origins"] = loyalty_config.google_wallet_origins or [] result["default_logo_url"] = loyalty_config.default_logo_url # Check Apple Wallet config too from app.modules.loyalty.services.apple_wallet_service import apple_wallet_service apple_config = apple_wallet_service.validate_config() result["apple"] = apple_config return result @router.get("/debug/classes") # noqa: API001 def debug_wallet_classes( current_user: User = Depends(get_current_super_admin_api), db: Session = Depends(get_db), ): """List all programs with their Google Wallet class status (super admin only).""" from app.modules.loyalty.services.google_wallet_service import google_wallet_service programs, _ = program_service.list_programs(db, skip=0, limit=1000) results = [] for program in programs: entry = { "program_id": program.id, "merchant_name": program.merchant.name if program.merchant else None, "program_name": program.display_name, "google_class_id": program.google_class_id, "review_status": "NOT_CREATED", "class_metadata": None, } if program.google_class_id: status = google_wallet_service.get_class_status(program.google_class_id) if status: entry["review_status"] = status.get("review_status", "UNKNOWN") entry["class_metadata"] = status else: entry["review_status"] = "UNKNOWN" results.append(entry) return {"programs": results} @router.post("/debug/classes/{program_id}/create") # noqa: API001 def debug_create_wallet_class( program_id: int = Path(..., gt=0), current_user: User = Depends(get_current_super_admin_api), db: Session = Depends(get_db), ): """Manually create a Google Wallet class for a program (super admin only).""" from app.modules.loyalty.services.google_wallet_service import google_wallet_service program = program_service.require_program(db, program_id) try: class_id = google_wallet_service.create_class(db, program) return { "success": True, "class_id": class_id, "program_id": program_id, } except Exception as exc: return { "success": False, "error": str(exc), "program_id": program_id, } @router.get("/debug/cards/{card_id}") # noqa: API001 def debug_inspect_card( card_id: int = Path(..., ge=0), card_number: str | None = Query(None, description="Search by card number instead"), current_user: User = Depends(get_current_super_admin_api), db: Session = Depends(get_db), ): """Inspect a card's wallet state (super admin only).""" from app.modules.loyalty.services.card_service import card_service from app.modules.loyalty.services.google_wallet_service import google_wallet_service card = None if card_number: card = card_service.get_card_by_number(db, card_number) elif card_id > 0: card = card_service.get_card(db, card_id) if not card: return {"error": "Card not found"} # Decode JWT preview if present jwt_info = None if card.google_object_jwt: try: import base64 import json parts = card.google_object_jwt.split(".") if len(parts) == 3: # Decode header and payload (without verification) payload_b64 = parts[1] + "=" * (4 - len(parts[1]) % 4) payload = json.loads(base64.urlsafe_b64decode(payload_b64)) jwt_type = "fat" if "payload" in payload and any( obj.get("classId") or obj.get("id") for obj in payload.get("payload", {}).get("loyaltyObjects", []) if isinstance(obj, dict) and ("classId" in obj or "state" in obj) ) else "reference" jwt_info = { "present": True, "type": jwt_type, "iss": payload.get("iss"), "aud": payload.get("aud"), "exp": payload.get("exp"), "truncated_token": card.google_object_jwt[:80] + "...", } except Exception: jwt_info = {"present": True, "type": "unknown", "decode_error": True} # Check if object exists in Google google_object_exists = None if card.google_object_id and google_wallet_service.is_configured: try: http = google_wallet_service._get_http_client() resp = http.get( f"https://walletobjects.googleapis.com/walletobjects/v1/loyaltyObject/{card.google_object_id}" ) google_object_exists = resp.status_code == 200 except Exception: google_object_exists = None return { "card_id": card.id, "card_number": card.card_number, "customer_email": card.customer.email if card.customer else None, "program_name": card.program.display_name if card.program else None, "program_id": card.program_id, "is_active": card.is_active, "google_object_id": card.google_object_id, "google_object_jwt": jwt_info, "has_google_wallet": bool(card.google_object_id), "google_object_exists_in_api": google_object_exists, "apple_serial_number": card.apple_serial_number, "has_apple_wallet": bool(card.apple_serial_number), "created_at": str(card.created_at) if card.created_at else None, } @router.post("/debug/cards/{card_id}/generate-url") # noqa: API001 def debug_generate_save_url( card_id: int = Path(..., gt=0), current_user: User = Depends(get_current_super_admin_api), db: Session = Depends(get_db), ): """Generate a fresh Google Wallet save URL for a card (super admin only).""" from app.modules.loyalty.services.card_service import card_service from app.modules.loyalty.services.google_wallet_service import google_wallet_service card = card_service.get_card(db, card_id) if not card: return {"error": "Card not found"} try: url = google_wallet_service.get_save_url(db, card) # Decode JWT to show preview jwt_preview = None if card.google_object_jwt: try: import base64 import json parts = card.google_object_jwt.split(".") if len(parts) == 3: payload_b64 = parts[1] + "=" * (4 - len(parts[1]) % 4) payload = json.loads(base64.urlsafe_b64decode(payload_b64)) # Determine if fat or reference JWT objects = payload.get("payload", {}).get("loyaltyObjects", []) is_fat = any( isinstance(obj, dict) and ("classId" in obj or "state" in obj) for obj in objects ) jwt_preview = { "type": "fat" if is_fat else "reference", "iss": payload.get("iss"), "aud": payload.get("aud"), "exp": payload.get("exp"), } except Exception: jwt_preview = {"type": "unknown"} return { "success": True, "url": url, "card_id": card_id, "jwt_preview": jwt_preview, } except Exception as exc: return { "success": False, "error": str(exc), "card_id": card_id, } @router.get("/debug/recent-enrollments") # noqa: API001 def debug_recent_enrollments( current_user: User = Depends(get_current_super_admin_api), db: Session = Depends(get_db), ): """Show the last 20 enrollments with wallet status (super admin only).""" from app.modules.loyalty.services.card_service import card_service cards = card_service.get_recent_cards(db, limit=20) results = [] for card in cards: has_object = bool(card.google_object_id) has_jwt = bool(card.google_object_jwt) if has_object: status = "wallet_ready" elif has_jwt: status = "jwt_only" else: status = "no_wallet" results.append({ "card_id": card.id, "card_number": card.card_number, "customer_email": card.customer.email if card.customer else None, "program_name": card.program.display_name if card.program else None, "enrolled_at": str(card.created_at) if card.created_at else None, "google_object_id": card.google_object_id, "has_google_jwt": has_jwt, "has_google_wallet": has_object, "has_apple_wallet": bool(card.apple_serial_number), "status": status, }) return {"enrollments": results} # ============================================================================= # Phase 6: Admin Operations (GDPR, Bulk, Point Restore) # ============================================================================= class GDPRAnonymizeResponse(BaseModel): """Response for GDPR customer anonymization.""" cards_anonymized: int customer_id: int message: str class BulkDeactivateRequest(BaseModel): """Request for bulk card deactivation.""" card_ids: list[int] = Field(..., min_length=1, max_length=1000) reason: str = Field(..., min_length=1, max_length=500) class BulkDeactivateResponse(BaseModel): cards_deactivated: int message: str class PointsRestoreRequest(BaseModel): """Request for admin point restore.""" points: int = Field(..., gt=0) reason: str = Field(..., min_length=1, max_length=500) class PointsRestoreResponse(BaseModel): success: bool card_id: int points_restored: int new_balance: int message: str @router.delete( "/cards/customer/{customer_id}", response_model=GDPRAnonymizeResponse, ) def gdpr_anonymize_customer( customer_id: int = Path(..., gt=0), current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """ GDPR: Anonymize a customer's loyalty cards. Nulls customer_id on all cards, deactivates them, and scrubs PII from transaction notes. Keeps transaction rows for aggregate reporting. """ count = card_service.anonymize_cards_for_customer( db, customer_id, admin_user_id=current_user.id ) return GDPRAnonymizeResponse( cards_anonymized=count, customer_id=customer_id, message=f"Anonymized {count} card(s) for customer {customer_id}", ) @router.post( "/merchants/{merchant_id}/cards/bulk/deactivate", response_model=BulkDeactivateResponse, ) def bulk_deactivate_cards( data: BulkDeactivateRequest, merchant_id: int = Path(..., gt=0), current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """Bulk deactivate multiple loyalty cards for a merchant.""" count = card_service.bulk_deactivate_cards( db, card_ids=data.card_ids, merchant_id=merchant_id, reason=data.reason, ) return BulkDeactivateResponse( cards_deactivated=count, message=f"Deactivated {count} card(s)", ) @router.post( "/cards/{card_id}/restore-points", response_model=PointsRestoreResponse, ) def restore_points( data: PointsRestoreRequest, card_id: int = Path(..., gt=0), current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """ Admin: Restore expired or voided points to a card. Creates an ADMIN_ADJUSTMENT transaction with a positive delta. """ from app.modules.loyalty.services.points_service import points_service result = points_service.adjust_points( db, card_id=card_id, points_delta=data.points, reason=f"Admin restore: {data.reason}", ) return PointsRestoreResponse( success=True, card_id=card_id, points_restored=data.points, new_balance=result["points_balance"], message=f"Restored {data.points} points", ) @router.post( "/merchants/{merchant_id}/restore-deleted", ) def restore_deleted_merchant_data( merchant_id: int = Path(..., gt=0), current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """ Cascade restore: restore all soft-deleted programs and cards for a merchant. """ programs_restored = program_service.restore_deleted_programs(db, merchant_id) cards_restored = card_service.restore_deleted_cards(db, merchant_id) logger.info( f"Cascade restore for merchant {merchant_id}: " f"{programs_restored} programs, {cards_restored} cards" ) return { "merchant_id": merchant_id, "programs_restored": programs_restored, "cards_restored": cards_restored, "message": f"Restored {programs_restored} program(s) and {cards_restored} card(s)", }