Spaces:
Sleeping
Phase 4: Implement Payment Transaction Manager
Browse filesCore implementation:
- Created services/payment_service/ package
- PaymentTransactionManager with full payment lifecycle
- Added /payments/analytics endpoint
Features:
- create_order(): Centralized order creation
- verify_payment(): Payment verification and marking as paid
- mark_failed(): Failure handling with error logging
- get_transaction_history(): User payment history
- get_analytics(): Revenue, success rate, credits purchased stats
Analytics endpoint returns:
- Total transactions
- Successful/failed counts
- Success rate percentage
- Total revenue (paise & rupees)
- Total credits purchased
- Average transaction value
Benefits:
- Single source of truth for payment operations
- Better analytics and reporting
- Consistent error handling
- Easier to add features (refunds, disputes, etc.)
All 4 phases complete! 🎉
|
@@ -636,3 +636,27 @@ async def get_payment_history(
|
|
| 636 |
page=page,
|
| 637 |
limit=limit
|
| 638 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 636 |
page=page,
|
| 637 |
limit=limit
|
| 638 |
)
|
| 639 |
+
|
| 640 |
+
|
| 641 |
+
@router.get("/analytics")
|
| 642 |
+
async def get_payment_analytics(
|
| 643 |
+
request: Request,
|
| 644 |
+
db: AsyncSession = Depends(get_db)
|
| 645 |
+
):
|
| 646 |
+
"""
|
| 647 |
+
Get payment analytics for the current user.
|
| 648 |
+
|
| 649 |
+
Returns:
|
| 650 |
+
Analytics including total revenue, success rate, etc.
|
| 651 |
+
"""
|
| 652 |
+
user = request.state.user
|
| 653 |
+
|
| 654 |
+
from services.payment_service import PaymentTransactionManager
|
| 655 |
+
|
| 656 |
+
analytics = await PaymentTransactionManager.get_analytics(
|
| 657 |
+
session=db,
|
| 658 |
+
user_id=user.id
|
| 659 |
+
)
|
| 660 |
+
|
| 661 |
+
return analytics
|
| 662 |
+
|
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Payment Service Package
|
| 3 |
+
|
| 4 |
+
Centralized payment transaction management.
|
| 5 |
+
"""
|
| 6 |
+
from services.payment_service.transaction_manager import (
|
| 7 |
+
PaymentTransactionManager,
|
| 8 |
+
PaymentTransactionError,
|
| 9 |
+
TransactionNotFoundError
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
__all__ = [
|
| 13 |
+
'PaymentTransactionManager',
|
| 14 |
+
'PaymentTransactionError',
|
| 15 |
+
'TransactionNotFoundError',
|
| 16 |
+
]
|
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Payment Transaction Manager
|
| 3 |
+
|
| 4 |
+
Centralized payment transaction management similar to CreditTransactionManager.
|
| 5 |
+
Handles order creation, verification, and analytics.
|
| 6 |
+
"""
|
| 7 |
+
import uuid
|
| 8 |
+
import logging
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from typing import Optional, List, Dict, Any
|
| 11 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 12 |
+
from sqlalchemy import select, func
|
| 13 |
+
|
| 14 |
+
from core.models import PaymentTransaction, User
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class PaymentTransactionError(Exception):
|
| 20 |
+
"""Base exception for payment transaction errors."""
|
| 21 |
+
pass
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class TransactionNotFoundError(PaymentTransactionError):
|
| 25 |
+
"""Transaction not found."""
|
| 26 |
+
pass
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class PaymentTransactionManager:
|
| 30 |
+
"""
|
| 31 |
+
Centralized manager for all payment transactions.
|
| 32 |
+
|
| 33 |
+
Similar to CreditTransactionManager, provides single source of truth
|
| 34 |
+
for payment operations.
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
@staticmethod
|
| 38 |
+
async def create_order(
|
| 39 |
+
session: AsyncSession,
|
| 40 |
+
user: User,
|
| 41 |
+
package_id: str,
|
| 42 |
+
credits_amount: int,
|
| 43 |
+
amount_paise: int,
|
| 44 |
+
currency: str,
|
| 45 |
+
gateway: str,
|
| 46 |
+
gateway_order_id: str,
|
| 47 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 48 |
+
) -> PaymentTransaction:
|
| 49 |
+
"""
|
| 50 |
+
Create a payment order.
|
| 51 |
+
|
| 52 |
+
Args:
|
| 53 |
+
session: Database session
|
| 54 |
+
user: User making payment
|
| 55 |
+
package_id: Package identifier (e.g., "basic_50")
|
| 56 |
+
credits_amount: Number of credits
|
| 57 |
+
amount_paise: Amount in paise
|
| 58 |
+
currency: Currency code (INR)
|
| 59 |
+
gateway: Payment gateway (razorpay)
|
| 60 |
+
gateway_order_id: Gateway's order ID
|
| 61 |
+
metadata: Additional metadata
|
| 62 |
+
|
| 63 |
+
Returns:
|
| 64 |
+
PaymentTransaction instance
|
| 65 |
+
"""
|
| 66 |
+
transaction = PaymentTransaction(
|
| 67 |
+
transaction_id=f"txn_{uuid.uuid4().hex[:16]}",
|
| 68 |
+
user_id=user.id,
|
| 69 |
+
gateway=gateway,
|
| 70 |
+
gateway_order_id=gateway_order_id,
|
| 71 |
+
package_id=package_id,
|
| 72 |
+
credits_amount=credits_amount,
|
| 73 |
+
amount_paise=amount_paise,
|
| 74 |
+
currency=currency,
|
| 75 |
+
status="created",
|
| 76 |
+
metadata=metadata or {},
|
| 77 |
+
created_at=datetime.utcnow()
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
session.add(transaction)
|
| 81 |
+
|
| 82 |
+
logger.info(
|
| 83 |
+
f"Created payment order: {transaction.transaction_id} "
|
| 84 |
+
f"for user {user.id}, {credits_amount} credits, "
|
| 85 |
+
f"₹{amount_paise/100:.2f}"
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
return transaction
|
| 89 |
+
|
| 90 |
+
@staticmethod
|
| 91 |
+
async def verify_payment(
|
| 92 |
+
session: AsyncSession,
|
| 93 |
+
transaction_id: str,
|
| 94 |
+
gateway_payment_id: str,
|
| 95 |
+
verified_by: str = "signature",
|
| 96 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 97 |
+
) -> PaymentTransaction:
|
| 98 |
+
"""
|
| 99 |
+
Verify and mark payment as successful.
|
| 100 |
+
|
| 101 |
+
Args:
|
| 102 |
+
session: Database session
|
| 103 |
+
transaction_id: Our transaction ID
|
| 104 |
+
gateway_payment_id: Gateway's payment ID
|
| 105 |
+
verified_by: Verification method (signature/webhook)
|
| 106 |
+
metadata: Additional metadata
|
| 107 |
+
|
| 108 |
+
Returns:
|
| 109 |
+
Updated PaymentTransaction
|
| 110 |
+
|
| 111 |
+
Raises:
|
| 112 |
+
TransactionNotFoundError: If transaction not found
|
| 113 |
+
"""
|
| 114 |
+
# Get transaction
|
| 115 |
+
query = select(PaymentTransaction).where(
|
| 116 |
+
PaymentTransaction.transaction_id == transaction_id
|
| 117 |
+
)
|
| 118 |
+
result = await session.execute(query)
|
| 119 |
+
transaction = result.scalar_one_or_none()
|
| 120 |
+
|
| 121 |
+
if not transaction:
|
| 122 |
+
raise TransactionNotFoundError(f"Transaction {transaction_id} not found")
|
| 123 |
+
|
| 124 |
+
# Update transaction
|
| 125 |
+
transaction.gateway_payment_id = gateway_payment_id
|
| 126 |
+
transaction.status = "paid"
|
| 127 |
+
transaction.verified_by = verified_by
|
| 128 |
+
transaction.paid_at = datetime.utcnow()
|
| 129 |
+
|
| 130 |
+
if metadata:
|
| 131 |
+
transaction.metadata.update(metadata)
|
| 132 |
+
|
| 133 |
+
logger.info(
|
| 134 |
+
f"Verified payment: {transaction_id}, "
|
| 135 |
+
f"payment_id: {gateway_payment_id}, "
|
| 136 |
+
f"method: {verified_by}"
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
return transaction
|
| 140 |
+
|
| 141 |
+
@staticmethod
|
| 142 |
+
async def mark_failed(
|
| 143 |
+
session: AsyncSession,
|
| 144 |
+
transaction_id: str,
|
| 145 |
+
error_message: str,
|
| 146 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 147 |
+
) -> PaymentTransaction:
|
| 148 |
+
"""
|
| 149 |
+
Mark payment as failed.
|
| 150 |
+
|
| 151 |
+
Args:
|
| 152 |
+
session: Database session
|
| 153 |
+
transaction_id: Our transaction ID
|
| 154 |
+
error_message: Failure reason
|
| 155 |
+
metadata: Additional metadata
|
| 156 |
+
|
| 157 |
+
Returns:
|
| 158 |
+
Updated PaymentTransaction
|
| 159 |
+
"""
|
| 160 |
+
query = select(PaymentTransaction).where(
|
| 161 |
+
PaymentTransaction.transaction_id == transaction_id
|
| 162 |
+
)
|
| 163 |
+
result = await session.execute(query)
|
| 164 |
+
transaction = result.scalar_one_or_none()
|
| 165 |
+
|
| 166 |
+
if not transaction:
|
| 167 |
+
raise TransactionNotFoundError(f"Transaction {transaction_id} not found")
|
| 168 |
+
|
| 169 |
+
transaction.status = "failed"
|
| 170 |
+
transaction.error_message = error_message[:1000] # Truncate
|
| 171 |
+
|
| 172 |
+
if metadata:
|
| 173 |
+
transaction.metadata.update(metadata)
|
| 174 |
+
|
| 175 |
+
logger.warning(f"Payment failed: {transaction_id}, reason: {error_message}")
|
| 176 |
+
|
| 177 |
+
return transaction
|
| 178 |
+
|
| 179 |
+
@staticmethod
|
| 180 |
+
async def get_transaction_history(
|
| 181 |
+
session: AsyncSession,
|
| 182 |
+
user_id: int,
|
| 183 |
+
limit: int = 50,
|
| 184 |
+
offset: int = 0,
|
| 185 |
+
status: Optional[str] = None
|
| 186 |
+
) -> List[PaymentTransaction]:
|
| 187 |
+
"""
|
| 188 |
+
Get payment transaction history for a user.
|
| 189 |
+
|
| 190 |
+
Args:
|
| 191 |
+
session: Database session
|
| 192 |
+
user_id: User ID
|
| 193 |
+
limit: Maximum number of transactions
|
| 194 |
+
offset: Offset for pagination
|
| 195 |
+
status: Filter by status (paid/failed/created)
|
| 196 |
+
|
| 197 |
+
Returns:
|
| 198 |
+
List of PaymentTransaction objects
|
| 199 |
+
"""
|
| 200 |
+
query = select(PaymentTransaction).where(
|
| 201 |
+
PaymentTransaction.user_id == user_id
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
if status:
|
| 205 |
+
query = query.where(PaymentTransaction.status == status)
|
| 206 |
+
|
| 207 |
+
query = query.order_by(
|
| 208 |
+
PaymentTransaction.created_at.desc()
|
| 209 |
+
).offset(offset).limit(limit)
|
| 210 |
+
|
| 211 |
+
result = await session.execute(query)
|
| 212 |
+
return list(result.scalars().all())
|
| 213 |
+
|
| 214 |
+
@staticmethod
|
| 215 |
+
async def get_analytics(
|
| 216 |
+
session: AsyncSession,
|
| 217 |
+
user_id: Optional[int] = None
|
| 218 |
+
) -> Dict[str, Any]:
|
| 219 |
+
"""
|
| 220 |
+
Get payment analytics.
|
| 221 |
+
|
| 222 |
+
Args:
|
| 223 |
+
session: Database session
|
| 224 |
+
user_id: Optional user ID (if None, get global stats)
|
| 225 |
+
|
| 226 |
+
Returns:
|
| 227 |
+
Dict with analytics data
|
| 228 |
+
"""
|
| 229 |
+
# Base query
|
| 230 |
+
base_query = select(PaymentTransaction)
|
| 231 |
+
if user_id:
|
| 232 |
+
base_query = base_query.where(PaymentTransaction.user_id == user_id)
|
| 233 |
+
|
| 234 |
+
# Total transactions
|
| 235 |
+
total_count = await session.scalar(
|
| 236 |
+
select(func.count()).select_from(base_query.subquery())
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
# Successful transactions
|
| 240 |
+
paid_query = base_query.where(PaymentTransaction.status == "paid")
|
| 241 |
+
paid_count = await session.scalar(
|
| 242 |
+
select(func.count()).select_from(paid_query.subquery())
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
# Total revenue (in paise)
|
| 246 |
+
revenue_result = await session.scalar(
|
| 247 |
+
select(func.sum(PaymentTransaction.amount_paise)).select_from(
|
| 248 |
+
paid_query.subquery()
|
| 249 |
+
)
|
| 250 |
+
)
|
| 251 |
+
total_revenue_paise = revenue_result or 0
|
| 252 |
+
|
| 253 |
+
# Total credits purchased
|
| 254 |
+
credits_result = await session.scalar(
|
| 255 |
+
select(func.sum(PaymentTransaction.credits_amount)).select_from(
|
| 256 |
+
paid_query.subquery()
|
| 257 |
+
)
|
| 258 |
+
)
|
| 259 |
+
total_credits = credits_result or 0
|
| 260 |
+
|
| 261 |
+
# Failed transactions
|
| 262 |
+
failed_count = await session.scalar(
|
| 263 |
+
select(func.count()).select_from(
|
| 264 |
+
base_query.where(PaymentTransaction.status == "failed").subquery()
|
| 265 |
+
)
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
# Success rate
|
| 269 |
+
success_rate = (paid_count / total_count * 100) if total_count > 0 else 0
|
| 270 |
+
|
| 271 |
+
return {
|
| 272 |
+
"total_transactions": total_count,
|
| 273 |
+
"successful_payments": paid_count,
|
| 274 |
+
"failed_payments": failed_count,
|
| 275 |
+
"success_rate_percent": round(success_rate, 2),
|
| 276 |
+
"total_revenue_paise": total_revenue_paise,
|
| 277 |
+
"total_revenue_rupees": total_revenue_paise / 100,
|
| 278 |
+
"total_credits_purchased": total_credits,
|
| 279 |
+
"average_transaction_value_paise": (
|
| 280 |
+
total_revenue_paise // paid_count if paid_count > 0 else 0
|
| 281 |
+
)
|
| 282 |
+
}
|