jebin2 commited on
Commit
de3cb16
Β·
1 Parent(s): 661c02e
app.py CHANGED
@@ -12,7 +12,7 @@ from fastapi.middleware.cors import CORSMiddleware
12
  from fastapi.responses import JSONResponse
13
 
14
  from core.database import init_db
15
- from routers import auth, blink, credits, general, gemini
16
  from services.drive_service import DriveService
17
 
18
  # Configure logging
@@ -87,6 +87,7 @@ app.include_router(auth.router)
87
  app.include_router(blink.router)
88
  app.include_router(gemini.router)
89
  app.include_router(credits.router)
 
90
 
91
 
92
  @app.exception_handler(Exception)
 
12
  from fastapi.responses import JSONResponse
13
 
14
  from core.database import init_db
15
+ from routers import auth, blink, credits, general, gemini, payments
16
  from services.drive_service import DriveService
17
 
18
  # Configure logging
 
87
  app.include_router(blink.router)
88
  app.include_router(gemini.router)
89
  app.include_router(credits.router)
90
+ app.include_router(payments.router)
91
 
92
 
93
  @app.exception_handler(Exception)
core/models.py CHANGED
@@ -152,3 +152,41 @@ class ApiKeyUsage(Base):
152
 
153
  def __repr__(self):
154
  return f"<ApiKeyUsage(index={self.key_index}, total={self.total_requests}, success={self.success_count}, failed={self.failure_count})>"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
 
153
  def __repr__(self):
154
  return f"<ApiKeyUsage(index={self.key_index}, total={self.total_requests}, success={self.success_count}, failed={self.failure_count})>"
155
+
156
+
157
+ class PaymentTransaction(Base):
158
+ """
159
+ Track payment transactions for credit purchases.
160
+ Supports multiple payment gateways (Razorpay now, Stripe later).
161
+ """
162
+ __tablename__ = "payment_transactions"
163
+
164
+ id = Column(Integer, primary_key=True, autoincrement=True, index=True)
165
+ transaction_id = Column(String(50), unique=True, index=True, nullable=False) # Our internal ID
166
+ user_id = Column(String(50), index=True, nullable=False) # Links to User.user_id
167
+
168
+ # Order details
169
+ gateway = Column(String(20), nullable=False) # razorpay, stripe
170
+ gateway_order_id = Column(String(100), index=True, nullable=True) # Razorpay order_id
171
+ gateway_payment_id = Column(String(100), index=True, nullable=True) # Razorpay payment_id
172
+
173
+ # Package details
174
+ package_id = Column(String(50), nullable=False) # starter, standard, pro
175
+ credits_amount = Column(Integer, nullable=False) # Credits to add
176
+ amount_paise = Column(Integer, nullable=False) # Amount in paise (INR * 100)
177
+ currency = Column(String(3), default="INR")
178
+
179
+ # Status tracking
180
+ status = Column(String(20), default="created", index=True) # created, paid, failed, refunded
181
+
182
+ # Timestamps
183
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
184
+ paid_at = Column(DateTime(timezone=True), nullable=True)
185
+
186
+ # Metadata
187
+ razorpay_signature = Column(String(255), nullable=True) # For verification audit
188
+ error_message = Column(Text, nullable=True)
189
+ extra_data = Column(JSON, nullable=True) # Additional gateway-specific data (renamed from 'metadata' - reserved)
190
+
191
+ def __repr__(self):
192
+ return f"<PaymentTransaction(id={self.transaction_id}, status={self.status}, amount={self.amount_paise})>"
requirements.txt CHANGED
@@ -15,4 +15,5 @@ google-auth-oauthlib>=1.0.0
15
  google-auth-httplib2>=0.1.0
16
  google-genai>=1.0.0
17
  PyJWT>=2.8.0
 
18
 
 
15
  google-auth-httplib2>=0.1.0
16
  google-genai>=1.0.0
17
  PyJWT>=2.8.0
18
+ razorpay>=1.4.0
19
 
routers/payments.py ADDED
@@ -0,0 +1,470 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Payments Router - API endpoints for credit purchases via Razorpay.
3
+
4
+ Endpoints:
5
+ - GET /payments/packages - List available credit packages
6
+ - POST /payments/create-order - Create a Razorpay order
7
+ - POST /payments/verify - Verify payment and add credits
8
+ - POST /payments/webhook/razorpay - Handle Razorpay webhooks
9
+ - GET /payments/history - Get user's payment history
10
+ """
11
+
12
+ import logging
13
+ import uuid
14
+ from datetime import datetime
15
+ from typing import List, Optional
16
+
17
+ from fastapi import APIRouter, Depends, HTTPException, Request, status
18
+ from pydantic import BaseModel
19
+ from sqlalchemy import select, desc
20
+ from sqlalchemy.ext.asyncio import AsyncSession
21
+
22
+ from core.database import get_db
23
+ from core.models import User, PaymentTransaction
24
+ from dependencies import get_current_user
25
+ from services.razorpay_service import (
26
+ RazorpayService,
27
+ RazorpayConfigError,
28
+ RazorpayOrderError,
29
+ RazorpayVerificationError,
30
+ get_razorpay_service,
31
+ is_razorpay_configured,
32
+ get_package,
33
+ list_packages,
34
+ CREDIT_PACKAGES
35
+ )
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ router = APIRouter(prefix="/payments", tags=["payments"])
40
+
41
+
42
+ # =============================================================================
43
+ # Request/Response Models
44
+ # =============================================================================
45
+
46
+ class PackageResponse(BaseModel):
47
+ """Credit package details."""
48
+ id: str
49
+ name: str
50
+ credits: int
51
+ amount_paise: int
52
+ amount_rupees: float
53
+ currency: str
54
+
55
+
56
+ class PackagesListResponse(BaseModel):
57
+ """List of available packages."""
58
+ packages: List[PackageResponse]
59
+
60
+
61
+ class CreateOrderRequest(BaseModel):
62
+ """Request to create a payment order."""
63
+ package_id: str
64
+
65
+
66
+ class CreateOrderResponse(BaseModel):
67
+ """Response with Razorpay order details."""
68
+ transaction_id: str
69
+ razorpay_order_id: str
70
+ amount_paise: int
71
+ currency: str
72
+ key_id: str
73
+ package_id: str
74
+ credits: int
75
+
76
+
77
+ class VerifyPaymentRequest(BaseModel):
78
+ """Request to verify a payment."""
79
+ razorpay_order_id: str
80
+ razorpay_payment_id: str
81
+ razorpay_signature: str
82
+
83
+
84
+ class VerifyPaymentResponse(BaseModel):
85
+ """Response after payment verification."""
86
+ success: bool
87
+ message: str
88
+ transaction_id: str
89
+ credits_added: int
90
+ new_balance: int
91
+
92
+
93
+ class PaymentHistoryItem(BaseModel):
94
+ """Single payment in history."""
95
+ transaction_id: str
96
+ package_id: str
97
+ credits_amount: int
98
+ amount_paise: int
99
+ currency: str
100
+ status: str
101
+ gateway: str
102
+ created_at: str
103
+ paid_at: Optional[str] = None
104
+
105
+
106
+ class PaymentHistoryResponse(BaseModel):
107
+ """Payment history response."""
108
+ transactions: List[PaymentHistoryItem]
109
+ total_count: int
110
+
111
+
112
+ # =============================================================================
113
+ # Helper Functions
114
+ # =============================================================================
115
+
116
+ def generate_transaction_id() -> str:
117
+ """Generate a unique transaction ID."""
118
+ return f"txn_{uuid.uuid4().hex[:16]}"
119
+
120
+
121
+ # =============================================================================
122
+ # Endpoints
123
+ # =============================================================================
124
+
125
+ @router.get("/packages", response_model=PackagesListResponse)
126
+ async def get_packages():
127
+ """
128
+ List all available credit packages.
129
+
130
+ No authentication required - this is public info for pricing display.
131
+ """
132
+ packages = list_packages()
133
+ return PackagesListResponse(
134
+ packages=[PackageResponse(**pkg) for pkg in packages]
135
+ )
136
+
137
+
138
+ @router.post("/create-order", response_model=CreateOrderResponse)
139
+ async def create_order(
140
+ request: CreateOrderRequest,
141
+ user: User = Depends(get_current_user),
142
+ db: AsyncSession = Depends(get_db)
143
+ ):
144
+ """
145
+ Create a Razorpay order for credit purchase.
146
+
147
+ The client should use the returned order_id to open
148
+ Razorpay checkout. After payment, call /verify endpoint.
149
+ """
150
+ # Check if Razorpay is configured
151
+ if not is_razorpay_configured():
152
+ raise HTTPException(
153
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
154
+ detail="Payment service is not configured"
155
+ )
156
+
157
+ # Get package
158
+ package = get_package(request.package_id)
159
+ if not package:
160
+ available = list(CREDIT_PACKAGES.keys())
161
+ raise HTTPException(
162
+ status_code=status.HTTP_400_BAD_REQUEST,
163
+ detail=f"Invalid package ID. Available packages: {available}"
164
+ )
165
+
166
+ try:
167
+ # Initialize Razorpay service
168
+ razorpay_service = get_razorpay_service()
169
+
170
+ # Generate our transaction ID
171
+ transaction_id = generate_transaction_id()
172
+
173
+ # Create Razorpay order
174
+ order = razorpay_service.create_order(
175
+ amount_paise=package.amount_paise,
176
+ transaction_id=transaction_id,
177
+ currency=package.currency,
178
+ notes={
179
+ "user_id": user.user_id,
180
+ "package_id": package.id,
181
+ "credits": str(package.credits)
182
+ }
183
+ )
184
+
185
+ # Save transaction to database
186
+ transaction = PaymentTransaction(
187
+ transaction_id=transaction_id,
188
+ user_id=user.user_id,
189
+ gateway="razorpay",
190
+ gateway_order_id=order["id"],
191
+ package_id=package.id,
192
+ credits_amount=package.credits,
193
+ amount_paise=package.amount_paise,
194
+ currency=package.currency,
195
+ status="created",
196
+ extra_data={"razorpay_order": order}
197
+ )
198
+ db.add(transaction)
199
+ await db.commit()
200
+
201
+ logger.info(
202
+ f"Created payment order: {transaction_id} for user {user.user_id}, "
203
+ f"package={package.id}, amount={package.amount_paise}"
204
+ )
205
+
206
+ return CreateOrderResponse(
207
+ transaction_id=transaction_id,
208
+ razorpay_order_id=order["id"],
209
+ amount_paise=package.amount_paise,
210
+ currency=package.currency,
211
+ key_id=razorpay_service.key_id,
212
+ package_id=package.id,
213
+ credits=package.credits
214
+ )
215
+
216
+ except RazorpayConfigError as e:
217
+ logger.error(f"Razorpay config error: {e}")
218
+ raise HTTPException(
219
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
220
+ detail="Payment service configuration error"
221
+ )
222
+ except RazorpayOrderError as e:
223
+ logger.error(f"Razorpay order error: {e}")
224
+ raise HTTPException(
225
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
226
+ detail="Failed to create payment order"
227
+ )
228
+
229
+
230
+ @router.post("/verify", response_model=VerifyPaymentResponse)
231
+ async def verify_payment(
232
+ request: VerifyPaymentRequest,
233
+ user: User = Depends(get_current_user),
234
+ db: AsyncSession = Depends(get_db)
235
+ ):
236
+ """
237
+ Verify Razorpay payment and add credits.
238
+
239
+ Called after successful Razorpay checkout.
240
+ Verifies the payment signature and credits the user.
241
+ """
242
+ try:
243
+ razorpay_service = get_razorpay_service()
244
+
245
+ # Find the transaction
246
+ result = await db.execute(
247
+ select(PaymentTransaction).where(
248
+ PaymentTransaction.gateway_order_id == request.razorpay_order_id,
249
+ PaymentTransaction.user_id == user.user_id
250
+ )
251
+ )
252
+ transaction = result.scalar_one_or_none()
253
+
254
+ if not transaction:
255
+ raise HTTPException(
256
+ status_code=status.HTTP_404_NOT_FOUND,
257
+ detail="Transaction not found"
258
+ )
259
+
260
+ # Check if already processed
261
+ if transaction.status == "paid":
262
+ return VerifyPaymentResponse(
263
+ success=True,
264
+ message="Payment already processed",
265
+ transaction_id=transaction.transaction_id,
266
+ credits_added=0,
267
+ new_balance=user.credits
268
+ )
269
+
270
+ # Verify signature
271
+ is_valid = razorpay_service.verify_payment_signature(
272
+ order_id=request.razorpay_order_id,
273
+ payment_id=request.razorpay_payment_id,
274
+ signature=request.razorpay_signature
275
+ )
276
+
277
+ if not is_valid:
278
+ transaction.status = "failed"
279
+ transaction.error_message = "Invalid payment signature"
280
+ await db.commit()
281
+
282
+ logger.warning(
283
+ f"Invalid payment signature for transaction {transaction.transaction_id}"
284
+ )
285
+
286
+ raise HTTPException(
287
+ status_code=status.HTTP_400_BAD_REQUEST,
288
+ detail="Payment verification failed"
289
+ )
290
+
291
+ # Update transaction
292
+ transaction.status = "paid"
293
+ transaction.gateway_payment_id = request.razorpay_payment_id
294
+ transaction.razorpay_signature = request.razorpay_signature
295
+ transaction.paid_at = datetime.utcnow()
296
+
297
+ # Add credits to user
298
+ user.credits += transaction.credits_amount
299
+
300
+ await db.commit()
301
+
302
+ logger.info(
303
+ f"Payment verified: {transaction.transaction_id}, "
304
+ f"added {transaction.credits_amount} credits to user {user.user_id}, "
305
+ f"new balance: {user.credits}"
306
+ )
307
+
308
+ return VerifyPaymentResponse(
309
+ success=True,
310
+ message="Payment successful! Credits added.",
311
+ transaction_id=transaction.transaction_id,
312
+ credits_added=transaction.credits_amount,
313
+ new_balance=user.credits
314
+ )
315
+
316
+ except RazorpayVerificationError as e:
317
+ logger.error(f"Payment verification error: {e}")
318
+ raise HTTPException(
319
+ status_code=status.HTTP_400_BAD_REQUEST,
320
+ detail="Payment verification failed"
321
+ )
322
+
323
+
324
+ @router.post("/webhook/razorpay")
325
+ async def razorpay_webhook(
326
+ request: Request,
327
+ db: AsyncSession = Depends(get_db)
328
+ ):
329
+ """
330
+ Handle Razorpay webhook events.
331
+
332
+ This provides backup verification in case the client-side
333
+ verification fails (e.g., network issues after payment).
334
+
335
+ Razorpay will retry webhooks for up to 24 hours if not acknowledged.
336
+ """
337
+ # Get signature from headers
338
+ signature = request.headers.get("X-Razorpay-Signature")
339
+ if not signature:
340
+ logger.warning("Webhook received without signature")
341
+ raise HTTPException(
342
+ status_code=status.HTTP_401_UNAUTHORIZED,
343
+ detail="Missing webhook signature"
344
+ )
345
+
346
+ # Get raw body for signature verification
347
+ body = await request.body()
348
+
349
+ try:
350
+ razorpay_service = get_razorpay_service()
351
+
352
+ # Verify webhook signature
353
+ if not razorpay_service.verify_webhook_signature(body, signature):
354
+ logger.warning("Invalid webhook signature")
355
+ raise HTTPException(
356
+ status_code=status.HTTP_401_UNAUTHORIZED,
357
+ detail="Invalid webhook signature"
358
+ )
359
+
360
+ # Parse webhook payload
361
+ import json
362
+ payload = json.loads(body)
363
+ event = payload.get("event")
364
+
365
+ logger.info(f"Received Razorpay webhook: {event}")
366
+
367
+ # Handle payment captured event
368
+ if event == "payment.captured":
369
+ payment = payload.get("payload", {}).get("payment", {}).get("entity", {})
370
+ order_id = payment.get("order_id")
371
+ payment_id = payment.get("id")
372
+
373
+ if order_id:
374
+ # Find transaction
375
+ result = await db.execute(
376
+ select(PaymentTransaction).where(
377
+ PaymentTransaction.gateway_order_id == order_id
378
+ )
379
+ )
380
+ transaction = result.scalar_one_or_none()
381
+
382
+ if transaction and transaction.status != "paid":
383
+ # Update transaction
384
+ transaction.status = "paid"
385
+ transaction.gateway_payment_id = payment_id
386
+ transaction.paid_at = datetime.utcnow()
387
+
388
+ # Find user and add credits
389
+ user_result = await db.execute(
390
+ select(User).where(User.user_id == transaction.user_id)
391
+ )
392
+ user = user_result.scalar_one_or_none()
393
+
394
+ if user:
395
+ user.credits += transaction.credits_amount
396
+ logger.info(
397
+ f"Webhook: Added {transaction.credits_amount} credits "
398
+ f"to user {user.user_id} for transaction {transaction.transaction_id}"
399
+ )
400
+
401
+ await db.commit()
402
+
403
+ # Handle payment failed event
404
+ elif event == "payment.failed":
405
+ payment = payload.get("payload", {}).get("payment", {}).get("entity", {})
406
+ order_id = payment.get("order_id")
407
+ error_reason = payment.get("error_description", "Payment failed")
408
+
409
+ if order_id:
410
+ result = await db.execute(
411
+ select(PaymentTransaction).where(
412
+ PaymentTransaction.gateway_order_id == order_id
413
+ )
414
+ )
415
+ transaction = result.scalar_one_or_none()
416
+
417
+ if transaction and transaction.status == "created":
418
+ transaction.status = "failed"
419
+ transaction.error_message = error_reason
420
+ await db.commit()
421
+
422
+ logger.info(
423
+ f"Webhook: Marked transaction {transaction.transaction_id} as failed"
424
+ )
425
+
426
+ return {"status": "ok"}
427
+
428
+ except RazorpayConfigError:
429
+ logger.error("Razorpay not configured for webhook processing")
430
+ raise HTTPException(
431
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
432
+ detail="Payment service not configured"
433
+ )
434
+
435
+
436
+ @router.get("/history", response_model=PaymentHistoryResponse)
437
+ async def get_payment_history(
438
+ user: User = Depends(get_current_user),
439
+ db: AsyncSession = Depends(get_db)
440
+ ):
441
+ """
442
+ Get user's payment history.
443
+
444
+ Returns all payment transactions ordered by newest first.
445
+ """
446
+ result = await db.execute(
447
+ select(PaymentTransaction)
448
+ .where(PaymentTransaction.user_id == user.user_id)
449
+ .order_by(desc(PaymentTransaction.created_at))
450
+ )
451
+ transactions = result.scalars().all()
452
+
453
+ history = []
454
+ for txn in transactions:
455
+ history.append(PaymentHistoryItem(
456
+ transaction_id=txn.transaction_id,
457
+ package_id=txn.package_id,
458
+ credits_amount=txn.credits_amount,
459
+ amount_paise=txn.amount_paise,
460
+ currency=txn.currency,
461
+ status=txn.status,
462
+ gateway=txn.gateway,
463
+ created_at=txn.created_at.isoformat() if txn.created_at else None,
464
+ paid_at=txn.paid_at.isoformat() if txn.paid_at else None
465
+ ))
466
+
467
+ return PaymentHistoryResponse(
468
+ transactions=history,
469
+ total_count=len(history)
470
+ )
services/razorpay_service.py ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Razorpay Payment Service - Modular, plug-and-play payment integration.
3
+
4
+ This module provides a complete Razorpay integration that can be easily
5
+ moved to another project with minimal changes.
6
+
7
+ Features:
8
+ - Create Razorpay orders for credit purchases
9
+ - Verify payment signatures (HMAC SHA256)
10
+ - Process webhook events
11
+ - Configurable credit packages
12
+
13
+ Usage:
14
+ from services.razorpay_service import RazorpayService, CREDIT_PACKAGES
15
+
16
+ # Initialize
17
+ service = RazorpayService() # Uses environment variables
18
+
19
+ # Or with explicit credentials
20
+ service = RazorpayService(
21
+ key_id="rzp_test_xxx",
22
+ key_secret="secret",
23
+ webhook_secret="webhook_secret"
24
+ )
25
+
26
+ # Create order
27
+ order = service.create_order(
28
+ amount_paise=9900,
29
+ transaction_id="txn_abc123",
30
+ notes={"user_id": "user123"}
31
+ )
32
+
33
+ # Verify payment
34
+ is_valid = service.verify_payment_signature(
35
+ order_id="order_xxx",
36
+ payment_id="pay_xxx",
37
+ signature="signature"
38
+ )
39
+
40
+ Environment Variables:
41
+ RAZORPAY_KEY_ID: Your Razorpay Key ID
42
+ RAZORPAY_KEY_SECRET: Your Razorpay Key Secret
43
+ RAZORPAY_WEBHOOK_SECRET: Webhook secret for signature verification
44
+ """
45
+
46
+ import os
47
+ import hmac
48
+ import hashlib
49
+ import logging
50
+ from typing import Optional, Dict, Any, List
51
+ from dataclasses import dataclass
52
+ from enum import Enum
53
+
54
+ import razorpay
55
+
56
+ logger = logging.getLogger(__name__)
57
+
58
+
59
+ # =============================================================================
60
+ # Credit Packages Configuration
61
+ # =============================================================================
62
+
63
+ @dataclass
64
+ class CreditPackage:
65
+ """Represents a purchasable credit package."""
66
+ id: str
67
+ name: str
68
+ credits: int
69
+ amount_paise: int # Amount in paise (INR Γ— 100)
70
+ currency: str = "INR"
71
+
72
+ @property
73
+ def amount_rupees(self) -> float:
74
+ """Get amount in rupees."""
75
+ return self.amount_paise / 100
76
+
77
+ def to_dict(self) -> Dict[str, Any]:
78
+ """Convert to dictionary for API responses."""
79
+ return {
80
+ "id": self.id,
81
+ "name": self.name,
82
+ "credits": self.credits,
83
+ "amount_paise": self.amount_paise,
84
+ "amount_rupees": self.amount_rupees,
85
+ "currency": self.currency
86
+ }
87
+
88
+
89
+ # Available credit packages
90
+ CREDIT_PACKAGES: Dict[str, CreditPackage] = {
91
+ "starter": CreditPackage(
92
+ id="starter",
93
+ name="Starter Pack",
94
+ credits=100,
95
+ amount_paise=9900 # β‚Ή99
96
+ ),
97
+ "standard": CreditPackage(
98
+ id="standard",
99
+ name="Standard Pack",
100
+ credits=500,
101
+ amount_paise=44900 # β‚Ή449
102
+ ),
103
+ "pro": CreditPackage(
104
+ id="pro",
105
+ name="Pro Pack",
106
+ credits=1000,
107
+ amount_paise=79900 # β‚Ή799
108
+ )
109
+ }
110
+
111
+
112
+ def get_package(package_id: str) -> Optional[CreditPackage]:
113
+ """Get a credit package by ID."""
114
+ return CREDIT_PACKAGES.get(package_id.lower())
115
+
116
+
117
+ def list_packages() -> List[Dict[str, Any]]:
118
+ """List all available credit packages."""
119
+ return [pkg.to_dict() for pkg in CREDIT_PACKAGES.values()]
120
+
121
+
122
+ # =============================================================================
123
+ # Payment Status Enum
124
+ # =============================================================================
125
+
126
+ class PaymentStatus(str, Enum):
127
+ """Payment transaction statuses."""
128
+ CREATED = "created"
129
+ AUTHORIZED = "authorized"
130
+ CAPTURED = "captured"
131
+ PAID = "paid"
132
+ FAILED = "failed"
133
+ REFUNDED = "refunded"
134
+
135
+
136
+ # =============================================================================
137
+ # Razorpay Service
138
+ # =============================================================================
139
+
140
+ class RazorpayServiceError(Exception):
141
+ """Base exception for Razorpay service errors."""
142
+ pass
143
+
144
+
145
+ class RazorpayConfigError(RazorpayServiceError):
146
+ """Raised when Razorpay is not properly configured."""
147
+ pass
148
+
149
+
150
+ class RazorpayOrderError(RazorpayServiceError):
151
+ """Raised when order creation fails."""
152
+ pass
153
+
154
+
155
+ class RazorpayVerificationError(RazorpayServiceError):
156
+ """Raised when payment verification fails."""
157
+ pass
158
+
159
+
160
+ class RazorpayService:
161
+ """
162
+ Modular Razorpay payment service.
163
+
164
+ This service handles all Razorpay interactions including:
165
+ - Order creation
166
+ - Payment signature verification
167
+ - Webhook signature verification
168
+
169
+ The service can be initialized with explicit credentials or will
170
+ automatically use environment variables.
171
+ """
172
+
173
+ def __init__(
174
+ self,
175
+ key_id: Optional[str] = None,
176
+ key_secret: Optional[str] = None,
177
+ webhook_secret: Optional[str] = None
178
+ ):
179
+ """
180
+ Initialize Razorpay service.
181
+
182
+ Args:
183
+ key_id: Razorpay Key ID (or RAZORPAY_KEY_ID env var)
184
+ key_secret: Razorpay Key Secret (or RAZORPAY_KEY_SECRET env var)
185
+ webhook_secret: Webhook secret (or RAZORPAY_WEBHOOK_SECRET env var)
186
+ """
187
+ self.key_id = key_id or os.getenv("RAZORPAY_KEY_ID")
188
+ self.key_secret = key_secret or os.getenv("RAZORPAY_KEY_SECRET")
189
+ self.webhook_secret = webhook_secret or os.getenv("RAZORPAY_WEBHOOK_SECRET")
190
+
191
+ if not self.key_id or not self.key_secret:
192
+ raise RazorpayConfigError(
193
+ "Razorpay credentials not configured. "
194
+ "Set RAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET environment variables."
195
+ )
196
+
197
+ # Initialize Razorpay client
198
+ self._client = razorpay.Client(auth=(self.key_id, self.key_secret))
199
+ logger.info("Razorpay service initialized")
200
+
201
+ @property
202
+ def is_configured(self) -> bool:
203
+ """Check if the service is properly configured."""
204
+ return bool(self.key_id and self.key_secret)
205
+
206
+ def create_order(
207
+ self,
208
+ amount_paise: int,
209
+ transaction_id: str,
210
+ currency: str = "INR",
211
+ notes: Optional[Dict[str, str]] = None
212
+ ) -> Dict[str, Any]:
213
+ """
214
+ Create a Razorpay order for payment.
215
+
216
+ Args:
217
+ amount_paise: Amount in paise (β‚Ή1 = 100 paise)
218
+ transaction_id: Your internal transaction/receipt ID
219
+ currency: Currency code (default: INR)
220
+ notes: Optional notes to attach to the order
221
+
222
+ Returns:
223
+ Razorpay order response containing order_id
224
+
225
+ Raises:
226
+ RazorpayOrderError: If order creation fails
227
+ """
228
+ try:
229
+ order_data = {
230
+ "amount": amount_paise,
231
+ "currency": currency,
232
+ "receipt": transaction_id,
233
+ "notes": notes or {}
234
+ }
235
+
236
+ order = self._client.order.create(data=order_data)
237
+
238
+ logger.info(
239
+ f"Created Razorpay order: {order['id']} "
240
+ f"for amount: {amount_paise} paise, "
241
+ f"receipt: {transaction_id}"
242
+ )
243
+
244
+ return order
245
+
246
+ except razorpay.errors.BadRequestError as e:
247
+ logger.error(f"Razorpay order creation failed (bad request): {e}")
248
+ raise RazorpayOrderError(f"Invalid order data: {e}")
249
+ except razorpay.errors.ServerError as e:
250
+ logger.error(f"Razorpay server error: {e}")
251
+ raise RazorpayOrderError(f"Razorpay server error: {e}")
252
+ except Exception as e:
253
+ logger.error(f"Unexpected error creating Razorpay order: {e}")
254
+ raise RazorpayOrderError(f"Failed to create order: {e}")
255
+
256
+ def verify_payment_signature(
257
+ self,
258
+ order_id: str,
259
+ payment_id: str,
260
+ signature: str
261
+ ) -> bool:
262
+ """
263
+ Verify Razorpay payment signature.
264
+
265
+ This MUST be called after receiving payment confirmation from the
266
+ client to ensure the payment is authentic.
267
+
268
+ Args:
269
+ order_id: Razorpay order ID
270
+ payment_id: Razorpay payment ID
271
+ signature: Razorpay signature from checkout response
272
+
273
+ Returns:
274
+ True if signature is valid
275
+
276
+ Raises:
277
+ RazorpayVerificationError: If signature verification fails
278
+ """
279
+ try:
280
+ # Generate expected signature
281
+ message = f"{order_id}|{payment_id}"
282
+ expected_signature = hmac.new(
283
+ self.key_secret.encode('utf-8'),
284
+ message.encode('utf-8'),
285
+ hashlib.sha256
286
+ ).hexdigest()
287
+
288
+ # Constant-time comparison to prevent timing attacks
289
+ is_valid = hmac.compare_digest(expected_signature, signature)
290
+
291
+ if is_valid:
292
+ logger.info(f"Payment signature verified: order={order_id}, payment={payment_id}")
293
+ else:
294
+ logger.warning(f"Invalid payment signature: order={order_id}, payment={payment_id}")
295
+
296
+ return is_valid
297
+
298
+ except Exception as e:
299
+ logger.error(f"Error verifying payment signature: {e}")
300
+ raise RazorpayVerificationError(f"Signature verification failed: {e}")
301
+
302
+ def verify_webhook_signature(
303
+ self,
304
+ body: bytes,
305
+ signature: str
306
+ ) -> bool:
307
+ """
308
+ Verify Razorpay webhook signature.
309
+
310
+ Use this to authenticate incoming webhook requests.
311
+
312
+ Args:
313
+ body: Raw request body bytes
314
+ signature: X-Razorpay-Signature header value
315
+
316
+ Returns:
317
+ True if webhook signature is valid
318
+ """
319
+ if not self.webhook_secret:
320
+ logger.warning("Webhook secret not configured, skipping verification")
321
+ return False
322
+
323
+ try:
324
+ expected_signature = hmac.new(
325
+ self.webhook_secret.encode('utf-8'),
326
+ body,
327
+ hashlib.sha256
328
+ ).hexdigest()
329
+
330
+ is_valid = hmac.compare_digest(expected_signature, signature)
331
+
332
+ if not is_valid:
333
+ logger.warning("Invalid webhook signature received")
334
+
335
+ return is_valid
336
+
337
+ except Exception as e:
338
+ logger.error(f"Error verifying webhook signature: {e}")
339
+ return False
340
+
341
+ def fetch_payment(self, payment_id: str) -> Dict[str, Any]:
342
+ """
343
+ Fetch payment details from Razorpay.
344
+
345
+ Args:
346
+ payment_id: Razorpay payment ID
347
+
348
+ Returns:
349
+ Payment details from Razorpay
350
+ """
351
+ try:
352
+ payment = self._client.payment.fetch(payment_id)
353
+ return payment
354
+ except Exception as e:
355
+ logger.error(f"Error fetching payment {payment_id}: {e}")
356
+ raise RazorpayServiceError(f"Failed to fetch payment: {e}")
357
+
358
+ def fetch_order(self, order_id: str) -> Dict[str, Any]:
359
+ """
360
+ Fetch order details from Razorpay.
361
+
362
+ Args:
363
+ order_id: Razorpay order ID
364
+
365
+ Returns:
366
+ Order details from Razorpay
367
+ """
368
+ try:
369
+ order = self._client.order.fetch(order_id)
370
+ return order
371
+ except Exception as e:
372
+ logger.error(f"Error fetching order {order_id}: {e}")
373
+ raise RazorpayServiceError(f"Failed to fetch order: {e}")
374
+
375
+
376
+ # =============================================================================
377
+ # Module-level convenience functions
378
+ # =============================================================================
379
+
380
+ _service_instance: Optional[RazorpayService] = None
381
+
382
+
383
+ def get_razorpay_service() -> RazorpayService:
384
+ """
385
+ Get or create a singleton Razorpay service instance.
386
+
387
+ Returns:
388
+ RazorpayService instance
389
+
390
+ Raises:
391
+ RazorpayConfigError: If Razorpay is not configured
392
+ """
393
+ global _service_instance
394
+
395
+ if _service_instance is None:
396
+ _service_instance = RazorpayService()
397
+
398
+ return _service_instance
399
+
400
+
401
+ def is_razorpay_configured() -> bool:
402
+ """Check if Razorpay credentials are configured in environment."""
403
+ return bool(
404
+ os.getenv("RAZORPAY_KEY_ID") and
405
+ os.getenv("RAZORPAY_KEY_SECRET")
406
+ )
tests/test_razorpay.py ADDED
@@ -0,0 +1,431 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test Razorpay Payment Integration.
3
+
4
+ This test file includes:
5
+ 1. Unit tests for RazorpayService (using real test API keys)
6
+ 2. Integration tests for payment endpoints
7
+ 3. End-to-end order creation flow
8
+
9
+ Run with: ./venv/bin/python -m pytest tests/test_razorpay.py -v
10
+ """
11
+
12
+ import pytest
13
+ import os
14
+ import sys
15
+ import hmac
16
+ import hashlib
17
+ from unittest.mock import patch, MagicMock, AsyncMock
18
+ from datetime import datetime
19
+
20
+ # Add parent directory
21
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
22
+
23
+ from dotenv import load_dotenv
24
+ load_dotenv()
25
+
26
+ from services.razorpay_service import (
27
+ RazorpayService,
28
+ RazorpayConfigError,
29
+ RazorpayOrderError,
30
+ CREDIT_PACKAGES,
31
+ get_package,
32
+ list_packages,
33
+ is_razorpay_configured
34
+ )
35
+
36
+
37
+ # =============================================================================
38
+ # Test Credit Packages
39
+ # =============================================================================
40
+
41
+ class TestCreditPackages:
42
+ """Test credit package configuration."""
43
+
44
+ def test_packages_defined(self):
45
+ """Verify all expected packages exist."""
46
+ assert "starter" in CREDIT_PACKAGES
47
+ assert "standard" in CREDIT_PACKAGES
48
+ assert "pro" in CREDIT_PACKAGES
49
+
50
+ def test_starter_package(self):
51
+ """Verify starter package details."""
52
+ pkg = get_package("starter")
53
+ assert pkg is not None
54
+ assert pkg.credits == 100
55
+ assert pkg.amount_paise == 9900 # β‚Ή99
56
+ assert pkg.currency == "INR"
57
+
58
+ def test_standard_package(self):
59
+ """Verify standard package details."""
60
+ pkg = get_package("standard")
61
+ assert pkg is not None
62
+ assert pkg.credits == 500
63
+ assert pkg.amount_paise == 44900 # β‚Ή449
64
+
65
+ def test_pro_package(self):
66
+ """Verify pro package details."""
67
+ pkg = get_package("pro")
68
+ assert pkg is not None
69
+ assert pkg.credits == 1000
70
+ assert pkg.amount_paise == 79900 # β‚Ή799
71
+
72
+ def test_get_invalid_package(self):
73
+ """Test getting non-existent package."""
74
+ assert get_package("nonexistent") is None
75
+
76
+ def test_list_packages(self):
77
+ """Test listing all packages."""
78
+ packages = list_packages()
79
+ assert len(packages) == 3
80
+ assert all("id" in p and "credits" in p and "amount_paise" in p for p in packages)
81
+
82
+ def test_package_to_dict(self):
83
+ """Test package serialization."""
84
+ pkg = get_package("starter")
85
+ d = pkg.to_dict()
86
+ assert d["id"] == "starter"
87
+ assert d["credits"] == 100
88
+ assert d["amount_rupees"] == 99.0
89
+
90
+
91
+ # =============================================================================
92
+ # Test Razorpay Service Configuration
93
+ # =============================================================================
94
+
95
+ class TestRazorpayServiceConfig:
96
+ """Test Razorpay service configuration."""
97
+
98
+ def test_is_configured(self):
99
+ """Check if Razorpay is configured (test keys should be set)."""
100
+ # This will pass if user has set RAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET
101
+ result = is_razorpay_configured()
102
+ print(f"\n Razorpay configured: {result}")
103
+ if not result:
104
+ pytest.skip("Razorpay not configured - set RAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET")
105
+
106
+ def test_service_initialization(self):
107
+ """Test service can be initialized with env vars."""
108
+ if not is_razorpay_configured():
109
+ pytest.skip("Razorpay not configured")
110
+
111
+ service = RazorpayService()
112
+ assert service.is_configured
113
+ assert service.key_id is not None
114
+ assert service.key_secret is not None
115
+
116
+ def test_service_with_invalid_credentials(self):
117
+ """Test service fails gracefully with no credentials."""
118
+ # Temporarily clear env vars
119
+ original_key = os.environ.pop("RAZORPAY_KEY_ID", None)
120
+ original_secret = os.environ.pop("RAZORPAY_KEY_SECRET", None)
121
+
122
+ try:
123
+ with pytest.raises(RazorpayConfigError):
124
+ RazorpayService()
125
+ finally:
126
+ # Restore env vars
127
+ if original_key:
128
+ os.environ["RAZORPAY_KEY_ID"] = original_key
129
+ if original_secret:
130
+ os.environ["RAZORPAY_KEY_SECRET"] = original_secret
131
+
132
+
133
+ # =============================================================================
134
+ # Test Order Creation (Real API Call with Test Keys)
135
+ # =============================================================================
136
+
137
+ class TestRazorpayOrderCreation:
138
+ """Test order creation with real Razorpay test API."""
139
+
140
+ @pytest.fixture
141
+ def razorpay_service(self):
142
+ """Get configured Razorpay service."""
143
+ if not is_razorpay_configured():
144
+ pytest.skip("Razorpay not configured")
145
+ return RazorpayService()
146
+
147
+ def test_create_order_starter_package(self, razorpay_service):
148
+ """Test creating order for starter package."""
149
+ package = get_package("starter")
150
+
151
+ order = razorpay_service.create_order(
152
+ amount_paise=package.amount_paise,
153
+ transaction_id=f"test_txn_{datetime.now().strftime('%Y%m%d%H%M%S')}",
154
+ notes={"test": "true", "package": "starter"}
155
+ )
156
+
157
+ print(f"\n Created order: {order['id']}")
158
+
159
+ assert "id" in order
160
+ assert order["id"].startswith("order_")
161
+ assert order["amount"] == package.amount_paise
162
+ assert order["currency"] == "INR"
163
+ assert order["status"] == "created"
164
+
165
+ def test_create_order_all_packages(self, razorpay_service):
166
+ """Test creating orders for all packages."""
167
+ for package_id, package in CREDIT_PACKAGES.items():
168
+ order = razorpay_service.create_order(
169
+ amount_paise=package.amount_paise,
170
+ transaction_id=f"test_{package_id}_{datetime.now().strftime('%H%M%S')}",
171
+ notes={"package": package_id}
172
+ )
173
+
174
+ print(f"\n {package_id}: order={order['id']}, amount=β‚Ή{order['amount']/100}")
175
+
176
+ assert order["amount"] == package.amount_paise
177
+
178
+ def test_fetch_order(self, razorpay_service):
179
+ """Test fetching order details."""
180
+ # First create an order
181
+ order = razorpay_service.create_order(
182
+ amount_paise=9900,
183
+ transaction_id=f"fetch_test_{datetime.now().strftime('%H%M%S')}"
184
+ )
185
+
186
+ # Fetch it back
187
+ fetched = razorpay_service.fetch_order(order["id"])
188
+
189
+ assert fetched["id"] == order["id"]
190
+ assert fetched["amount"] == 9900
191
+
192
+
193
+ # =============================================================================
194
+ # Test Signature Verification
195
+ # =============================================================================
196
+
197
+ class TestSignatureVerification:
198
+ """Test payment signature verification."""
199
+
200
+ @pytest.fixture
201
+ def razorpay_service(self):
202
+ """Get configured Razorpay service."""
203
+ if not is_razorpay_configured():
204
+ pytest.skip("Razorpay not configured")
205
+ return RazorpayService()
206
+
207
+ def test_verify_valid_signature(self, razorpay_service):
208
+ """Test verification with a valid signature."""
209
+ order_id = "order_test123"
210
+ payment_id = "pay_test456"
211
+
212
+ # Generate valid signature
213
+ message = f"{order_id}|{payment_id}"
214
+ valid_signature = hmac.new(
215
+ razorpay_service.key_secret.encode('utf-8'),
216
+ message.encode('utf-8'),
217
+ hashlib.sha256
218
+ ).hexdigest()
219
+
220
+ result = razorpay_service.verify_payment_signature(
221
+ order_id=order_id,
222
+ payment_id=payment_id,
223
+ signature=valid_signature
224
+ )
225
+
226
+ assert result is True
227
+
228
+ def test_verify_invalid_signature(self, razorpay_service):
229
+ """Test verification with an invalid signature."""
230
+ result = razorpay_service.verify_payment_signature(
231
+ order_id="order_test123",
232
+ payment_id="pay_test456",
233
+ signature="invalid_signature_abc123"
234
+ )
235
+
236
+ assert result is False
237
+
238
+ def test_verify_webhook_signature(self, razorpay_service):
239
+ """Test webhook signature verification."""
240
+ if not razorpay_service.webhook_secret:
241
+ pytest.skip("Webhook secret not configured")
242
+
243
+ body = b'{"event":"payment.captured"}'
244
+
245
+ # Generate valid webhook signature
246
+ valid_signature = hmac.new(
247
+ razorpay_service.webhook_secret.encode('utf-8'),
248
+ body,
249
+ hashlib.sha256
250
+ ).hexdigest()
251
+
252
+ result = razorpay_service.verify_webhook_signature(body, valid_signature)
253
+ assert result is True
254
+
255
+ # Test invalid signature
256
+ result = razorpay_service.verify_webhook_signature(body, "invalid")
257
+ assert result is False
258
+
259
+
260
+ # =============================================================================
261
+ # Test Payment Endpoints (Integration)
262
+ # =============================================================================
263
+
264
+ class TestPaymentEndpoints:
265
+ """Integration tests for payment API endpoints."""
266
+
267
+ @pytest.fixture
268
+ def client(self):
269
+ """Create test client."""
270
+ from fastapi.testclient import TestClient
271
+
272
+ # Set required env vars for testing
273
+ os.environ.setdefault("JWT_SECRET", "test-secret-key-for-jwt-testing")
274
+ os.environ.setdefault("GOOGLE_CLIENT_ID", "test.apps.googleusercontent.com")
275
+ os.environ.setdefault("RESET_DB", "true")
276
+
277
+ with patch("services.drive_service.DriveService") as mock_drive:
278
+ mock_instance = MagicMock()
279
+ mock_instance.download_db.return_value = False
280
+ mock_instance.upload_db.return_value = True
281
+ mock_drive.return_value = mock_instance
282
+
283
+ from app import app
284
+ with TestClient(app) as c:
285
+ yield c
286
+
287
+ def test_get_packages_no_auth(self, client):
288
+ """Test packages endpoint doesn't require auth."""
289
+ response = client.get("/payments/packages")
290
+
291
+ assert response.status_code == 200
292
+ data = response.json()
293
+
294
+ assert "packages" in data
295
+ assert len(data["packages"]) == 3
296
+
297
+ # Verify all packages present
298
+ package_ids = [p["id"] for p in data["packages"]]
299
+ assert "starter" in package_ids
300
+ assert "standard" in package_ids
301
+ assert "pro" in package_ids
302
+
303
+ print(f"\n Packages: {[p['id'] + '@β‚Ή' + str(p['amount_rupees']) for p in data['packages']]}")
304
+
305
+ def test_create_order_requires_auth(self, client):
306
+ """Test create-order endpoint requires authentication."""
307
+ response = client.post(
308
+ "/payments/create-order",
309
+ json={"package_id": "starter"}
310
+ )
311
+
312
+ assert response.status_code == 401
313
+
314
+ def test_verify_requires_auth(self, client):
315
+ """Test verify endpoint requires authentication."""
316
+ response = client.post(
317
+ "/payments/verify",
318
+ json={
319
+ "razorpay_order_id": "order_test",
320
+ "razorpay_payment_id": "pay_test",
321
+ "razorpay_signature": "sig_test"
322
+ }
323
+ )
324
+
325
+ assert response.status_code == 401
326
+
327
+ def test_history_requires_auth(self, client):
328
+ """Test history endpoint requires authentication."""
329
+ response = client.get("/payments/history")
330
+ assert response.status_code == 401
331
+
332
+
333
+ # =============================================================================
334
+ # Run Standalone Test Script
335
+ # =============================================================================
336
+
337
+ def run_manual_tests():
338
+ """
339
+ Run manual tests - useful for quick verification.
340
+
341
+ Usage: ./venv/bin/python tests/test_razorpay.py
342
+ """
343
+ print("\n" + "="*60)
344
+ print("RAZORPAY INTEGRATION TEST")
345
+ print("="*60)
346
+
347
+ # Check configuration
348
+ print("\n1. Checking Razorpay configuration...")
349
+ if not is_razorpay_configured():
350
+ print(" ❌ Razorpay NOT configured!")
351
+ print(" Please set RAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET in .env")
352
+ return
353
+ print(" βœ“ Razorpay is configured")
354
+
355
+ # Initialize service
356
+ print("\n2. Initializing RazorpayService...")
357
+ try:
358
+ service = RazorpayService()
359
+ print(f" βœ“ Service initialized")
360
+ print(f" Key ID: {service.key_id[:15]}...")
361
+ except Exception as e:
362
+ print(f" ❌ Failed: {e}")
363
+ return
364
+
365
+ # List packages
366
+ print("\n3. Credit packages:")
367
+ for pkg in list_packages():
368
+ print(f" β€’ {pkg['name']}: {pkg['credits']} credits @ β‚Ή{pkg['amount_rupees']}")
369
+
370
+ # Create test order
371
+ print("\n4. Creating test order (β‚Ή99 Starter pack)...")
372
+ try:
373
+ order = service.create_order(
374
+ amount_paise=9900,
375
+ transaction_id=f"manual_test_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
376
+ notes={"test": "manual", "source": "test_razorpay.py"}
377
+ )
378
+ print(f" βœ“ Order created!")
379
+ print(f" Order ID: {order['id']}")
380
+ print(f" Amount: β‚Ή{order['amount']/100}")
381
+ print(f" Status: {order['status']}")
382
+ except Exception as e:
383
+ print(f" ❌ Failed: {e}")
384
+ return
385
+
386
+ # Test signature verification
387
+ print("\n5. Testing signature verification...")
388
+ test_signature = hmac.new(
389
+ service.key_secret.encode(),
390
+ f"{order['id']}|pay_test123".encode(),
391
+ hashlib.sha256
392
+ ).hexdigest()
393
+
394
+ valid = service.verify_payment_signature(order['id'], "pay_test123", test_signature)
395
+ print(f" βœ“ Valid signature: {valid}")
396
+
397
+ invalid = service.verify_payment_signature(order['id'], "pay_test123", "wrong_sig")
398
+ print(f" βœ“ Invalid signature rejected: {not invalid}")
399
+
400
+ # Test API endpoints
401
+ print("\n6. Testing API endpoints...")
402
+ from fastapi.testclient import TestClient
403
+
404
+ os.environ.setdefault("JWT_SECRET", "test-secret")
405
+ os.environ.setdefault("GOOGLE_CLIENT_ID", "test.apps.googleusercontent.com")
406
+ os.environ.setdefault("RESET_DB", "true")
407
+
408
+ with patch("services.drive_service.DriveService"):
409
+ from app import app
410
+ with TestClient(app) as client:
411
+ # Test packages endpoint
412
+ resp = client.get("/payments/packages")
413
+ print(f" GET /payments/packages: {resp.status_code}")
414
+
415
+ # Test auth requirement
416
+ resp = client.post("/payments/create-order", json={"package_id": "starter"})
417
+ print(f" POST /payments/create-order (no auth): {resp.status_code} (expected 401)")
418
+
419
+ print("\n" + "="*60)
420
+ print("βœ“ All manual tests passed!")
421
+ print("="*60)
422
+ print("\nNext steps:")
423
+ print("1. Start your server: ./venv/bin/uvicorn app:app --reload")
424
+ print("2. Login to get JWT token")
425
+ print("3. Call POST /payments/create-order with token")
426
+ print("4. Use returned order_id in Razorpay checkout")
427
+ print("")
428
+
429
+
430
+ if __name__ == "__main__":
431
+ run_manual_tests()