Files
TrisolarisServer/src/main/kotlin/com/android/trisolarisserver/controller/RazorpayQrPayments.kt
androidlover5842 2421ba5edf
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
Store Razorpay test keys alongside live keys
2026-02-01 15:19:26 +05:30

342 lines
15 KiB
Kotlin

package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.component.RazorpayQrEvents
import com.android.trisolarisserver.controller.dto.RazorpayQrGenerateRequest
import com.android.trisolarisserver.controller.dto.RazorpayQrEventResponse
import com.android.trisolarisserver.controller.dto.RazorpayQrGenerateResponse
import com.android.trisolarisserver.controller.dto.RazorpayQrRecordResponse
import com.android.trisolarisserver.models.booking.BookingStatus
import com.android.trisolarisserver.models.payment.RazorpayQrRequest
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.db.repo.BookingRepo
import com.android.trisolarisserver.repo.RazorpayQrRequestRepo
import com.android.trisolarisserver.repo.RazorpaySettingsRepo
import com.android.trisolarisserver.repo.RazorpayWebhookLogRepo
import com.android.trisolarisserver.security.MyPrincipal
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
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.RestController
import org.springframework.web.client.RestTemplate
import org.springframework.web.server.ResponseStatusException
import org.springframework.http.HttpStatus
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
import java.time.OffsetDateTime
import java.util.Base64
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/bookings/{bookingId}/payments/razorpay")
class RazorpayQrPayments(
private val propertyAccess: PropertyAccess,
private val bookingRepo: BookingRepo,
private val settingsRepo: RazorpaySettingsRepo,
private val qrRequestRepo: RazorpayQrRequestRepo,
private val webhookLogRepo: RazorpayWebhookLogRepo,
private val qrEvents: RazorpayQrEvents,
private val restTemplate: RestTemplate,
private val objectMapper: ObjectMapper
) {
@PostMapping("/qr")
@Transactional
fun createQr(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@RequestBody request: RazorpayQrGenerateRequest,
@AuthenticationPrincipal principal: MyPrincipal?
): RazorpayQrGenerateResponse {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
val booking = bookingRepo.findById(bookingId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
}
if (booking.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
}
if (booking.status == BookingStatus.CANCELLED || booking.status == BookingStatus.NO_SHOW) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Booking is not active")
}
val settings = settingsRepo.findByPropertyId(propertyId)
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay settings not configured")
val amount = request.amount ?: 0L
if (amount <= 0) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "amount must be > 0")
}
val currency = booking.property.currency
val existing = qrRequestRepo.findTopByBookingIdAndAmountAndCurrencyAndStatusOrderByCreatedAtDesc(
bookingId,
amount,
currency,
"active"
)
if (existing != null && existing.qrId != null) {
return RazorpayQrGenerateResponse(
qrId = existing.qrId,
amount = existing.amount,
currency = existing.currency,
imageUrl = existing.imageUrl
)
}
val expirySeconds = request.expirySeconds ?: request.expiryMinutes?.let { it * 60 } ?: 600
val expiresAt = expirySeconds?.let { OffsetDateTime.now().plusSeconds(it.toLong()) }
val notes = mapOf(
"bookingId" to bookingId.toString(),
"propertyId" to propertyId.toString()
)
val payload = linkedMapOf<String, Any>(
"type" to "upi_qr",
"name" to "Booking $bookingId",
"usage" to "single_use",
"fixed_amount" to true,
"payment_amount" to amount * 100,
"notes" to notes
)
payload["close_by"] = OffsetDateTime.now().plusSeconds(expirySeconds.toLong()).toEpochSecond()
val requestPayload = objectMapper.writeValueAsString(payload)
val response = postJson(resolveBaseUrl(settings.isTest) + "/payments/qr_codes", settings, requestPayload)
val body = response.body ?: "{}"
val node = objectMapper.readTree(body)
val qrId = node.path("id").asText(null)
val status = node.path("status").asText("unknown")
val imageUrl = node.path("image_url").asText(null)
val record = qrRequestRepo.save(
RazorpayQrRequest(
property = booking.property,
booking = booking,
qrId = qrId,
amount = amount,
currency = currency,
status = status,
imageUrl = imageUrl,
requestPayload = requestPayload,
responsePayload = body,
expiryAt = expiresAt
)
)
if (!response.statusCode.is2xxSuccessful) {
record.status = "failed"
qrRequestRepo.save(record)
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "Razorpay request failed")
}
return RazorpayQrGenerateResponse(
qrId = qrId,
amount = amount,
currency = currency,
imageUrl = imageUrl
)
}
@GetMapping("/qr/active")
fun getActiveQr(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): RazorpayQrGenerateResponse? {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
val booking = bookingRepo.findById(bookingId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
}
if (booking.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
}
val active = qrRequestRepo.findTopByBookingIdAndStatusOrderByCreatedAtDesc(bookingId, "active") ?: return null
return RazorpayQrGenerateResponse(
qrId = active.qrId,
amount = active.amount,
currency = active.currency,
imageUrl = active.imageUrl
)
}
@PostMapping("/qr/close")
@Transactional
fun closeActiveQr(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): RazorpayQrGenerateResponse? {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
val booking = bookingRepo.findById(bookingId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
}
if (booking.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
}
val active = qrRequestRepo.findTopByBookingIdAndStatusOrderByCreatedAtDesc(bookingId, "active") ?: return null
val settings = settingsRepo.findByPropertyId(propertyId)
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay settings not configured")
val qrId = active.qrId ?: return null
val response = postJson(resolveBaseUrl(settings.isTest) + "/payments/qr_codes/$qrId/close", settings, "{}")
if (!response.statusCode.is2xxSuccessful) {
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "Razorpay close request failed")
}
active.status = "closed"
qrRequestRepo.save(active)
return RazorpayQrGenerateResponse(
qrId = active.qrId,
amount = active.amount,
currency = active.currency,
imageUrl = active.imageUrl
)
}
@PostMapping("/qr/{qrId}/close")
@Transactional
fun closeQrById(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@PathVariable qrId: String,
@AuthenticationPrincipal principal: MyPrincipal?
): RazorpayQrGenerateResponse? {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
val booking = bookingRepo.findById(bookingId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
}
if (booking.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
}
val record = qrRequestRepo.findTopByQrIdOrderByCreatedAtDesc(qrId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "QR not found")
val settings = settingsRepo.findByPropertyId(propertyId)
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay settings not configured")
val response = postJson(resolveBaseUrl(settings.isTest) + "/payments/qr_codes/$qrId/close", settings, "{}")
if (!response.statusCode.is2xxSuccessful) {
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "Razorpay close request failed")
}
record.status = "closed"
qrRequestRepo.save(record)
return RazorpayQrGenerateResponse(
qrId = record.qrId,
amount = record.amount,
currency = record.currency,
imageUrl = record.imageUrl
)
}
@GetMapping("/qr/{qrId}/events")
fun qrEvents(
@PathVariable propertyId: UUID,
@PathVariable qrId: String,
@AuthenticationPrincipal principal: MyPrincipal?
): List<RazorpayQrEventResponse> {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
val logs = webhookLogRepo.findByPropertyIdOrderByReceivedAtDesc(propertyId)
val out = mutableListOf<RazorpayQrEventResponse>()
for (log in logs) {
val payload = log.payload ?: continue
val node = runCatching { objectMapper.readTree(payload) }.getOrNull() ?: continue
val event = node.path("event").asText(null)
val qrEntity = node.path("payload").path("qr_code").path("entity")
val eventQrId = qrEntity.path("id").asText(null)
if (eventQrId != qrId) continue
val status = qrEntity.path("status").asText(null)
out.add(
RazorpayQrEventResponse(
event = event,
qrId = eventQrId,
status = status,
receivedAt = log.receivedAt.toString()
)
)
}
return out
}
@GetMapping("/qr/{qrId}/events/stream", produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
fun streamQrEvents(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@PathVariable qrId: String,
@AuthenticationPrincipal principal: MyPrincipal?,
response: HttpServletResponse
): SseEmitter {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
prepareSse(response)
return qrEvents.subscribe(propertyId, qrId)
}
private fun prepareSse(response: HttpServletResponse) {
response.setHeader("Cache-Control", "no-cache")
response.setHeader("Connection", "keep-alive")
response.setHeader("X-Accel-Buffering", "no")
}
@GetMapping("/qr")
fun listQr(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<RazorpayQrRecordResponse> {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
val booking = bookingRepo.findById(bookingId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
}
if (booking.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
}
return qrRequestRepo.findByBookingIdOrderByCreatedAtDesc(bookingId).map { qr ->
RazorpayQrRecordResponse(
qrId = qr.qrId,
amount = qr.amount,
currency = qr.currency,
status = qr.status,
imageUrl = qr.imageUrl,
expiryAt = qr.expiryAt?.toString(),
createdAt = qr.createdAt.toString(),
responsePayload = qr.responsePayload
)
}
}
private fun postJson(url: String, settings: com.android.trisolarisserver.models.payment.RazorpaySettings, json: String): ResponseEntity<String> {
val (keyId, keySecret) = requireKeys(settings)
val headers = HttpHeaders()
headers.contentType = MediaType.APPLICATION_JSON
headers.set(HttpHeaders.AUTHORIZATION, basicAuth(keyId, keySecret))
return restTemplate.exchange(url, HttpMethod.POST, HttpEntity(json, headers), String::class.java)
}
private fun resolveBaseUrl(isTest: Boolean): String {
return if (isTest) {
"https://api.razorpay.com/v1"
} else {
"https://api.razorpay.com/v1"
}
}
private fun basicAuth(keyId: String, keySecret: String): String {
val raw = "$keyId:$keySecret"
val encoded = Base64.getEncoder().encodeToString(raw.toByteArray())
return "Basic $encoded"
}
private fun requireKeys(settings: com.android.trisolarisserver.models.payment.RazorpaySettings): Pair<String, String> {
val keyId = settings.resolveKeyId()
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay test keys not configured")
val keySecret = settings.resolveKeySecret()
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay test keys not configured")
return keyId to keySecret
}
}