Files
TrisolarisServer/src/main/kotlin/com/android/trisolarisserver/controller/RazorpayWebhookCapture.kt
androidlover5842 35b15f37ec
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
Add SSE for Razorpay QR events
2026-02-01 12:22:16 +05:30

173 lines
7.6 KiB
Kotlin

package com.android.trisolarisserver.controller
import com.android.trisolarisserver.db.repo.BookingRepo
import com.android.trisolarisserver.models.booking.Payment
import com.android.trisolarisserver.models.booking.PaymentMethod
import com.android.trisolarisserver.models.payment.RazorpayPaymentAttempt
import com.android.trisolarisserver.models.payment.RazorpayWebhookLog
import com.android.trisolarisserver.component.RazorpayQrEvents
import com.android.trisolarisserver.controller.dto.RazorpayQrEventResponse
import com.android.trisolarisserver.repo.PaymentRepo
import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.repo.RazorpayPaymentAttemptRepo
import com.android.trisolarisserver.repo.RazorpayQrRequestRepo
import com.android.trisolarisserver.repo.RazorpaySettingsRepo
import com.android.trisolarisserver.repo.RazorpayWebhookLogRepo
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.servlet.http.HttpServletRequest
import org.springframework.http.HttpStatus
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import java.time.OffsetDateTime
import java.util.UUID
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
@RestController
@RequestMapping("/properties/{propertyId}/razorpay/webhook")
class RazorpayWebhookCapture(
private val propertyRepo: PropertyRepo,
private val bookingRepo: BookingRepo,
private val paymentRepo: PaymentRepo,
private val settingsRepo: RazorpaySettingsRepo,
private val razorpayPaymentAttemptRepo: RazorpayPaymentAttemptRepo,
private val razorpayQrRequestRepo: RazorpayQrRequestRepo,
private val razorpayWebhookLogRepo: RazorpayWebhookLogRepo,
private val razorpayQrEvents: RazorpayQrEvents,
private val objectMapper: ObjectMapper
) {
@PostMapping
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional
fun capture(
@PathVariable propertyId: UUID,
@RequestBody(required = false) body: String?,
request: HttpServletRequest
) {
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val settings = settingsRepo.findByPropertyId(propertyId)
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay settings not configured")
val headers = request.headerNames.toList().associateWith { request.getHeader(it) }
val headersText = headers.entries.joinToString("\n") { (k, v) -> "$k: $v" }
razorpayWebhookLogRepo.save(
RazorpayWebhookLog(
property = property,
headers = headersText,
payload = body,
contentType = request.contentType,
receivedAt = OffsetDateTime.now()
)
)
if (body.isNullOrBlank()) return
val signature = request.getHeader("X-Razorpay-Signature")
?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing signature")
val secret = settings.webhookSecret
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Webhook secret not configured")
if (!verifySignature(body, secret, signature)) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid signature")
}
val root = objectMapper.readTree(body)
val event = root.path("event").asText(null)
val paymentEntity = root.path("payload").path("payment").path("entity")
val orderEntity = root.path("payload").path("order").path("entity")
val qrEntity = root.path("payload").path("qr_code").path("entity")
val paymentId = paymentEntity.path("id").asText(null)
val orderId = paymentEntity.path("order_id").asText(null)?.takeIf { it.isNotBlank() }
?: orderEntity.path("id").asText(null)?.takeIf { it.isNotBlank() }
val status = paymentEntity.path("status").asText(null)
val amountPaise = paymentEntity.path("amount").asLong(0)
val currency = paymentEntity.path("currency").asText(property.currency)
val notes = paymentEntity.path("notes")
val bookingId = notes.path("bookingId").asText(null)?.let { runCatching { UUID.fromString(it) }.getOrNull() }
?: orderEntity.path("receipt").asText(null)?.let { runCatching { UUID.fromString(it) }.getOrNull() }
val booking = bookingId?.let { bookingRepo.findById(it).orElse(null) }
if (booking != null && booking.property.id != propertyId) return
val qrId = qrEntity.path("id").asText(null)
val qrStatus = qrEntity.path("status").asText(null)
if (event != null && event.startsWith("qr_code.") && qrId != null) {
val existingQr = razorpayQrRequestRepo.findTopByQrIdOrderByCreatedAtDesc(qrId)
if (existingQr != null) {
if (!qrStatus.isNullOrBlank()) {
existingQr.status = qrStatus
razorpayQrRequestRepo.save(existingQr)
}
}
razorpayQrEvents.emit(
propertyId,
qrId,
RazorpayQrEventResponse(
event = event,
qrId = qrId,
status = qrStatus,
receivedAt = OffsetDateTime.now().toString()
)
)
}
razorpayPaymentAttemptRepo.save(
RazorpayPaymentAttempt(
property = property,
booking = booking,
event = event,
status = status,
amount = paiseToAmount(amountPaise),
currency = currency,
paymentId = paymentId,
orderId = orderId,
payload = body,
receivedAt = OffsetDateTime.now()
)
)
if (event == null || paymentId == null || booking == null) return
if (event != "payment.captured" && event != "refund.processed") return
if (paymentRepo.findByGatewayPaymentId(paymentId) != null) return
val signedAmount = if (event == "refund.processed") -paiseToAmount(amountPaise) else paiseToAmount(amountPaise)
val notesText = "razorpay event=$event status=$status order_id=$orderId"
paymentRepo.save(
Payment(
property = booking.property,
booking = booking,
amount = signedAmount,
currency = booking.property.currency,
method = PaymentMethod.ONLINE,
gatewayPaymentId = paymentId,
gatewayTxnId = orderId,
reference = "razorpay:$paymentId",
notes = notesText,
receivedAt = OffsetDateTime.now()
)
)
}
private fun verifySignature(payload: String, secret: String, signature: String): Boolean {
return try {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(secret.toByteArray(), "HmacSHA256"))
val hash = mac.doFinal(payload.toByteArray()).joinToString("") { "%02x".format(it) }
hash.equals(signature, ignoreCase = true)
} catch (_: Exception) {
false
}
}
private fun paiseToAmount(paise: Long): Long {
return paise / 100
}
}