313 lines
13 KiB
Python
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)
|
|
}
|