ifixkart-backend/app/services/ServicePaymentService.py

313 lines
13 KiB
Python

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)
}