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( "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 { requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF) val logs = webhookLogRepo.findByPropertyIdOrderByReceivedAtDesc(propertyId) val out = mutableListOf() 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 { 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 { 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 { 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 } }