import ulid from datetime import datetime from sqlalchemy.orm import Session from sqlalchemy import select from typing import Optional, List from decimal import Decimal from fastapi import HTTPException, status from app.models.ServiceModel import ServiceJob, ServiceAppointment, ServiceJobEvent from app.models.ServiceQuoteModel import ServiceJobQuote from app.models.ServicePaymentModel import ServicePayment from app.schemas.ServicePaymentSchema import RazorpayVerificationRequest, ServicePaymentCreate from app.core.razorpay import razorpay_service class ServicePaymentService: def initiate_payment( self, db: Session, job_id: str, payload: ServicePaymentCreate, customer_id: Optional[str] = None ) -> dict: job = db.get(ServiceJob, job_id) if not job: raise HTTPException(status_code=404, detail="Service job not found") payment_type = payload.payment_type quote_id = payload.quote_id if payload.amount and payload.amount > 0: amount = Decimal(str(payload.amount)) elif payment_type == "ADVANCE": base_price = Decimal(str(job.base_price_snapshot if job.base_price_snapshot is not None else (job.service.base_price if job.service else 0.0))) doorstep_fee = Decimal("250.00") if job.fulfillment_type == "DOORSTEP_PICKUP" else Decimal("0.00") total = base_price + doorstep_fee amount = (total * Decimal("0.20")).quantize(Decimal("0.01")) elif payment_type == "INSPECTION": amount = Decimal(str(job.inspection_fee_snapshot or 300.00)) elif payment_type == "ADDITIONAL": if not quote_id: raise HTTPException(status_code=400, detail="Quote ID required for additional damage payment") quote = db.get(ServiceJobQuote, quote_id) if not quote: raise HTTPException(status_code=404, detail="Quote not found") amount = Decimal(str(quote.additional_damage_amount)) elif payment_type == "FINAL": base_price = Decimal(str(job.base_price_snapshot if job.base_price_snapshot is not None else (job.service.base_price if job.service else 0.0))) stmt = select(ServiceJobQuote).where(ServiceJobQuote.service_job_id == job_id).order_by(ServiceJobQuote.version.desc()) latest_quote = db.execute(stmt).scalars().first() total_cost = Decimal(str(latest_quote.total if latest_quote else base_price)) paid_advance_stmt = select(ServicePayment).where( ServicePayment.service_job_id == job_id, ServicePayment.payment_type == "ADVANCE", ServicePayment.status == "CAPTURED" ) paid_advance = db.execute(paid_advance_stmt).scalars().all() advance_paid = sum(Decimal(str(p.amount)) for p in paid_advance) amount = total_cost - advance_paid else: raise HTTPException(status_code=400, detail="Invalid payment type") if amount <= 0: raise HTTPException(status_code=400, detail="Payment amount must be greater than zero") # Create Razorpay order amount_paise = int(amount * 100) rzp_order = razorpay_service.create_order(amount_paise, "INR") # Create payment record payment = ServicePayment( payment_id=str(ulid.ULID()), service_job_id=job_id, quote_id=quote_id, payment_type=payment_type, amount=amount, status="PENDING", provider="RAZORPAY", provider_order_id=rzp_order["id"] ) db.add(payment) db.commit() db.refresh(payment) from app.core.config.Config import settings return { "payment_id": payment.payment_id, "rzp_order_id": rzp_order["id"], "amount": amount_paise, "currency": "INR", "rzp_key_id": settings.RAZORPAY_KEY_ID } def verify_payment(self, db: Session, job_id: str, payload: RazorpayVerificationRequest) -> dict: return self.verify_milestone_payment(db, payload) def initiate_milestone_payment( self, db: Session, job_id: str, customer_id: str, payment_type: str, quote_id: Optional[str] = None ) -> dict: job = db.get(ServiceJob, job_id) if not job or job.customer_id != customer_id: raise HTTPException(status_code=404, detail="Service job not found") # Determine Amount based on payment type base_price = Decimal(str(job.base_price_snapshot if job.base_price_snapshot is not None else (job.service.base_price if job.service else 0.0))) amount = Decimal("0.00") if payment_type == "ADVANCE": # 20% of base price snapshot amount = base_price * Decimal("0.20") elif payment_type == "INSPECTION": amount = Decimal(str(job.inspection_fee_snapshot or 300.00)) elif payment_type == "ADDITIONAL": if not quote_id: raise HTTPException(status_code=400, detail="Quote ID required for additional damage payment") quote = db.get(ServiceJobQuote, quote_id) if not quote: raise HTTPException(status_code=404, detail="Quote not found") amount = Decimal(str(quote.additional_damage_amount)) elif payment_type == "FINAL": # Total quote amount minus advance paid stmt = select(ServiceJobQuote).where(ServiceJobQuote.service_job_id == job_id).order_by(ServiceJobQuote.version.desc()) latest_quote = db.execute(stmt).scalars().first() total_cost = Decimal(str(latest_quote.total if latest_quote else base_price)) # Find advance already paid paid_advance_stmt = select(ServicePayment).where( ServicePayment.service_job_id == job_id, ServicePayment.payment_type == "ADVANCE", ServicePayment.status == "CAPTURED" ) paid_advance = db.execute(paid_advance_stmt).scalars().all() advance_paid = sum(Decimal(str(p.amount)) for p in paid_advance) amount = total_cost - advance_paid else: raise HTTPException(status_code=400, detail="Invalid payment type") if amount <= 0: raise HTTPException(status_code=400, detail="Payment amount must be greater than zero") # Create Razorpay order amount_paise = int(amount * 100) rzp_order = razorpay_service.create_order(amount_paise, "INR") # Create payment record payment = ServicePayment( payment_id=str(ulid.ULID()), service_job_id=job_id, quote_id=quote_id, payment_type=payment_type, amount=amount, status="PENDING", provider="RAZORPAY", provider_order_id=rzp_order["id"] ) db.add(payment) db.commit() db.refresh(payment) from app.core.config.Config import settings return { "payment_id": payment.payment_id, "rzp_order_id": rzp_order["id"], "amount": amount_paise, "currency": "INR", "rzp_key_id": settings.RAZORPAY_KEY_ID } def verify_payment(self, db: Session, job_id: str, payload: RazorpayVerificationRequest) -> dict: return self.verify_milestone_payment(db, payload, job_id=job_id) def verify_milestone_payment(self, db: Session, payload: RazorpayVerificationRequest, job_id: Optional[str] = None) -> dict: # Verify Razorpay signature is_valid = razorpay_service.verify_payment_signature( rzp_order_id=payload.razorpay_order_id, rzp_payment_id=payload.razorpay_payment_id, rzp_signature=payload.razorpay_signature ) if not is_valid: raise HTTPException(status_code=400, detail="Invalid signature verification failed") # Multi-strategy payment record lookup payment = None if hasattr(payload, 'payment_id') and payload.payment_id: payment = db.get(ServicePayment, payload.payment_id) if not payment and payload.razorpay_order_id: stmt = select(ServicePayment).where(ServicePayment.provider_order_id == payload.razorpay_order_id) payment = db.execute(stmt).scalar_one_or_none() if not payment and job_id: stmt = select(ServicePayment).where( ServicePayment.service_job_id == job_id, ServicePayment.status == "PENDING" ).order_by(ServicePayment.created_at.desc()) payment = db.execute(stmt).scalars().first() if not payment: raise HTTPException(status_code=404, detail="Matching payment record not found") if payment.status == "CAPTURED": return {"message": "Payment already processed", "job_id": payment.service_job_id} payment.status = "CAPTURED" payment.provider_payment_id = payload.razorpay_payment_id payment.provider_signature = payload.razorpay_signature payment.paid_at = datetime.utcnow() # Update Job / Appointment depending on milestone type job = db.get(ServiceJob, payment.service_job_id) if job: if payment.payment_type == "ADVANCE": job.status = "BOOKED" # Mark appointment as CONFIRMED app_stmt = select(ServiceAppointment).where( ServiceAppointment.service_job_id == job.job_id, ServiceAppointment.status == "HELD" ) appt = db.execute(app_stmt).scalar_one_or_none() if appt: appt.status = "CONFIRMED" appt.confirmed_at = datetime.utcnow() elif payment.payment_type == "FINAL": job.status = "READY_FOR_DELIVERY" # Log timeline event event = ServiceJobEvent( event_id=str(ulid.ULID()), job_id=job.job_id, event_type="PAYMENT_RECEIVED", performed_by=job.customer_id or "GUEST", notes=f"Milestone payment {payment.payment_type} of {payment.amount} INR verified and processed." ) db.add(event) db.commit() return {"message": "Payment verified successfully", "job_id": payment.service_job_id} def process_quote_rejection_refund(self, db: Session, job_id: str) -> dict: job = db.get(ServiceJob, job_id) if not job: raise HTTPException(status_code=404, detail="Service job not found") # 1. Fetch total captured advance paid paid_advances_stmt = select(ServicePayment).where( ServicePayment.service_job_id == job_id, ServicePayment.payment_type == "ADVANCE", ServicePayment.status == "CAPTURED" ) paid_advances = db.execute(paid_advances_stmt).scalars().all() advance_paid = sum(Decimal(str(p.amount)) for p in paid_advances) fee = Decimal(str(job.inspection_fee_snapshot or 300.00)) if advance_paid >= fee: # Settle inspection fee from advance settled_pmt = ServicePayment( payment_id=str(ulid.ULID()), service_job_id=job_id, payment_type="INSPECTION", amount=fee, status="SETTLED", provider="INTERNAL_SETTLEMENT" ) db.add(settled_pmt) # Refund remaining balance refund_amount = advance_paid - fee if refund_amount > 0: refund_pmt = ServicePayment( payment_id=str(ulid.ULID()), service_job_id=job_id, payment_type="REFUND", amount=refund_amount, status="REFUNDED", provider="RAZORPAY", provider_refund_id=f"rfnd_{ulid.ULID()}", refund_reason="REVISED_QUOTE_REJECTED", refunded_at=datetime.utcnow() ) db.add(refund_pmt) job.status = "RETURN_PENDING" else: if advance_paid > 0: settled_pmt = ServicePayment( payment_id=str(ulid.ULID()), service_job_id=job_id, payment_type="INSPECTION", amount=advance_paid, status="SETTLED", provider="INTERNAL_SETTLEMENT" ) db.add(settled_pmt) job.status = "INSPECTION_FEE_PENDING" event = ServiceJobEvent( event_id=str(ulid.ULID()), job_id=job_id, event_type="QUOTE_REJECTED", performed_by=job.customer_id, notes=f"Customer rejected revised quote. Inspection fee {fee} INR processed. Job status: {job.status}." ) db.add(event) db.commit() return { "message": "Quote rejection processed", "job_id": job.job_id, "status": job.status, "advance_paid": float(advance_paid), "inspection_fee": float(fee) }