package com.android.trisolarisserver.controller import com.android.trisolarisserver.component.PropertyAccess import com.android.trisolarisserver.controller.dto.PayuQrGenerateRequest import com.android.trisolarisserver.controller.dto.PayuQrGenerateResponse import com.android.trisolarisserver.models.booking.BookingStatus import com.android.trisolarisserver.models.payment.PayuQrRequest import com.android.trisolarisserver.models.payment.PayuQrStatus import com.android.trisolarisserver.models.property.Role import com.android.trisolarisserver.db.repo.BookingRepo import com.android.trisolarisserver.repo.PaymentRepo import com.android.trisolarisserver.repo.PayuQrRequestRepo import com.android.trisolarisserver.repo.PayuSettingsRepo import com.android.trisolarisserver.repo.RoomStayRepo import com.android.trisolarisserver.security.MyPrincipal import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.transaction.annotation.Transactional import org.springframework.util.LinkedMultiValueMap 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 jakarta.servlet.http.HttpServletRequest import java.security.MessageDigest import java.time.OffsetDateTime import java.util.UUID @RestController @RequestMapping("/properties/{propertyId}/bookings/{bookingId}/payments/payu") class PayuQrPayments( private val propertyAccess: PropertyAccess, private val bookingRepo: BookingRepo, private val roomStayRepo: RoomStayRepo, private val paymentRepo: PaymentRepo, private val payuSettingsRepo: PayuSettingsRepo, private val payuQrRequestRepo: PayuQrRequestRepo, private val restTemplate: RestTemplate ) { private val defaultExpirySeconds = 30 * 60 @PostMapping("/qr") @Transactional fun generateQr( @PathVariable propertyId: UUID, @PathVariable bookingId: UUID, @AuthenticationPrincipal principal: MyPrincipal?, @RequestBody request: PayuQrGenerateRequest, httpRequest: HttpServletRequest ): PayuQrGenerateResponse { requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.FINANCE) 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 || booking.status == BookingStatus.CHECKED_OUT) { throw ResponseStatusException(HttpStatus.CONFLICT, "Booking closed") } val settings = payuSettingsRepo.findByPropertyId(propertyId) ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "PayU settings not configured") val salt = pickSalt(settings) val stays = roomStayRepo.findByBookingId(bookingId) val expectedPay = computeExpectedPay(stays, booking.property.timezone) val collected = paymentRepo.sumAmountByBookingId(bookingId) val pending = expectedPay - collected if (pending <= 0) { throw ResponseStatusException(HttpStatus.CONFLICT, "No pending amount") } val requestedAmount = request.amount?.takeIf { it > 0 } if (requestedAmount != null && requestedAmount > pending) { throw ResponseStatusException(HttpStatus.CONFLICT, "Amount exceeds pending") } val amountLong = requestedAmount ?: pending val expirySeconds = request.expirySeconds ?: request.expiryMinutes?.let { it * 60 } ?: defaultExpirySeconds val existing = payuQrRequestRepo.findTopByBookingIdAndAmountAndCurrencyAndStatusOrderByCreatedAtDesc( bookingId, amountLong, booking.property.currency, PayuQrStatus.SENT ) if (existing != null) { val expiryAt = existing.expiryAt val responsePayload = existing.responsePayload if (expiryAt != null && !responsePayload.isNullOrBlank()) { val now = OffsetDateTime.now() if (now.isBefore(expiryAt)) { return PayuQrGenerateResponse( txnid = existing.txnid, amount = amountLong, currency = booking.property.currency, payuResponse = responsePayload ) } } } val txnid = "QR${bookingId.toString().substring(0, 8)}${System.currentTimeMillis()}" val productInfo = "Booking $bookingId" val guest = booking.primaryGuest ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Booking missing guest") val firstname = guest.name?.trim()?.ifBlank { null } ?: "Guest" val phone = guest.phoneE164?.trim()?.ifBlank { null } ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest phone missing") val email = "guest-${bookingId.toString().substring(0, 8)}@hoteltrisolaris.in" val amount = String.format("%.2f", amountLong.toDouble()) val udf1 = bookingId.toString() val udf2 = propertyId.toString() val udf3 = request.udf3?.trim()?.ifBlank { "" } ?: "" val udf4 = request.udf4?.trim()?.ifBlank { "" } ?: "" val udf5 = request.udf5?.trim()?.ifBlank { "" } ?: "" val hash = sha512( listOf( settings.merchantKey, txnid, amount, productInfo, firstname, email, udf1, udf2, udf3, udf4, udf5, "", "", "", "", "", salt ).joinToString("|") ) val form = LinkedMultiValueMap().apply { set("key", settings.merchantKey) set("txnid", txnid) set("amount", amount) set("productinfo", productInfo) set("firstname", firstname) set("email", email) set("phone", phone) set("surl", buildReturnUrl(propertyId, true)) set("furl", buildReturnUrl(propertyId, false)) set("pg", "DBQR") set("bankcode", "UPIDBQR") set("hash", hash) set("udf1", udf1) set("udf2", udf2) set("udf3", udf3) // always set("udf4", udf4) // always set("udf5", udf5) // always set("txn_s2s_flow", "4") val clientIp = request.clientIp?.trim()?.ifBlank { null } ?: extractClientIp(httpRequest) ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "clientIp required") val deviceInfo = request.deviceInfo?.trim()?.ifBlank { null } ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "deviceInfo required") set("s2s_client_ip", clientIp) set("s2s_device_info", deviceInfo) set("expiry_time", expirySeconds.toString()) request.address1?.trim()?.ifBlank { null }?.let { add("address1", it) } request.address2?.trim()?.ifBlank { null }?.let { add("address2", it) } request.city?.trim()?.ifBlank { null }?.let { add("city", it) } request.state?.trim()?.ifBlank { null }?.let { add("state", it) } request.country?.trim()?.ifBlank { null }?.let { add("country", it) } request.zipcode?.trim()?.ifBlank { null }?.let { add("zipcode", it) } } val requestPayload = form.entries.joinToString("&") { entry -> entry.value.joinToString("&") { value -> "${entry.key}=$value" } } val createdAt = OffsetDateTime.now() val expiryAt = createdAt.plusSeconds(expirySeconds.toLong()) val record = payuQrRequestRepo.save( PayuQrRequest( property = booking.property, booking = booking, txnid = txnid, amount = amountLong, currency = booking.property.currency, status = PayuQrStatus.CREATED, requestPayload = requestPayload, expiryAt = expiryAt, createdAt = createdAt ) ) val headers = org.springframework.http.HttpHeaders().apply { contentType = MediaType.APPLICATION_FORM_URLENCODED } val entity = org.springframework.http.HttpEntity(form, headers) val response = restTemplate.postForEntity(resolveBaseUrl(settings), entity, String::class.java) val responseBody = response.body ?: "" record.responsePayload = responseBody record.status = if (response.statusCode.is2xxSuccessful) PayuQrStatus.SENT else PayuQrStatus.FAILED payuQrRequestRepo.save(record) if (!response.statusCode.is2xxSuccessful) { throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "PayU request failed") } return PayuQrGenerateResponse( txnid = txnid, amount = amountLong, currency = booking.property.currency, payuResponse = responseBody ) } private fun pickSalt(settings: com.android.trisolarisserver.models.payment.PayuSettings): String { val salt = if (settings.useSalt256) settings.salt256 else settings.salt32 return salt?.trim()?.ifBlank { null } ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "PayU salt missing") } private fun buildReturnUrl(propertyId: UUID, success: Boolean): String { val path = if (success) "success" else "failure" return "https://api.hoteltrisolaris.in/properties/$propertyId/payu/return/$path" } private fun resolveBaseUrl(settings: com.android.trisolarisserver.models.payment.PayuSettings): String { return if (settings.isTest) { "https://test.payu.in/_payment" } else { "https://secure.payu.in/_payment" } } private fun sha512(input: String): String { val bytes = MessageDigest.getInstance("SHA-512").digest(input.toByteArray(Charsets.UTF_8)) return bytes.joinToString("") { "%02x".format(it) } } private fun extractClientIp(request: HttpServletRequest): String? { val forwarded = request.getHeader("X-Forwarded-For") ?.split(",") ?.firstOrNull() ?.trim() ?.ifBlank { null } if (forwarded != null) return forwarded val realIp = request.getHeader("X-Real-IP")?.trim()?.ifBlank { null } if (realIp != null) return realIp return request.remoteAddr?.trim()?.ifBlank { null } } private fun computeExpectedPay(stays: List, timezone: String?): Long { if (stays.isEmpty()) return 0 val now = nowForProperty(timezone) var total = 0L stays.forEach { stay -> val rate = stay.nightlyRate ?: 0L if (rate == 0L) return@forEach val start = stay.fromAt.toLocalDate() val endAt = stay.toAt ?: now val end = endAt.toLocalDate() val nights = daysBetweenInclusive(start, end) total += rate * nights } return total } private fun daysBetweenInclusive(start: java.time.LocalDate, end: java.time.LocalDate): Long { val diff = end.toEpochDay() - start.toEpochDay() return if (diff <= 0) 1L else diff } }