package com.android.trisolarisserver.controller import com.android.trisolarisserver.component.PropertyAccess import com.android.trisolarisserver.controller.dto.PayuPaymentLinkCreateRequest import com.android.trisolarisserver.controller.dto.PayuPaymentLinkCreateResponse import com.android.trisolarisserver.models.booking.BookingStatus 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.PayuPaymentLinkSettingsRepo import com.android.trisolarisserver.repo.RoomStayRepo import com.android.trisolarisserver.security.MyPrincipal import com.fasterxml.jackson.databind.ObjectMapper import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders 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.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 java.time.OffsetDateTime import java.util.UUID @RestController @RequestMapping("/properties/{propertyId}/bookings/{bookingId}/payments/payu") class PayuPaymentLinksController( private val propertyAccess: PropertyAccess, private val bookingRepo: BookingRepo, private val roomStayRepo: RoomStayRepo, private val paymentRepo: PaymentRepo, private val settingsRepo: PayuPaymentLinkSettingsRepo, private val restTemplate: RestTemplate, private val objectMapper: ObjectMapper ) { @PostMapping("/link") @Transactional fun createPaymentLink( @PathVariable propertyId: UUID, @PathVariable bookingId: UUID, @AuthenticationPrincipal principal: MyPrincipal?, @RequestBody request: PayuPaymentLinkCreateRequest ): PayuPaymentLinkCreateResponse { 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 = settingsRepo.findByPropertyId(propertyId) ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "PayU payment link settings not configured") 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 isAmountFilledByCustomer = request.isAmountFilledByCustomer ?: false val requestedAmount = request.amount?.takeIf { it > 0 } if (!isAmountFilledByCustomer && requestedAmount == null) { // default to pending if not open amount } if (requestedAmount != null && requestedAmount > pending) { throw ResponseStatusException(HttpStatus.CONFLICT, "Amount exceeds pending") } val amountLong = if (isAmountFilledByCustomer) null else (requestedAmount ?: pending) val guest = booking.primaryGuest ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Booking missing guest") val customerName = guest.name?.trim()?.ifBlank { null } ?: "Guest" val customerPhone = guest.phoneE164?.trim()?.ifBlank { null } ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest phone missing") val customerEmail = "guest-${bookingId.toString().substring(0, 8)}@hoteltrisolaris.in" val body = mutableMapOf( "description" to (request.description?.trim()?.ifBlank { null } ?: "Booking $bookingId"), "source" to "API", "isPartialPaymentAllowed" to (request.isPartialPaymentAllowed ?: false), "isAmountFilledByCustomer" to isAmountFilledByCustomer, "customer" to mapOf( "name" to customerName, "email" to customerEmail, "phone" to customerPhone ), "udf" to mapOf( "udf1" to bookingId.toString(), "udf2" to propertyId.toString(), "udf3" to (request.udf3?.trim()?.ifBlank { null }), "udf4" to (request.udf4?.trim()?.ifBlank { null }), "udf5" to (request.udf5?.trim()?.ifBlank { null }) ), "viaEmail" to (request.viaEmail ?: false), "viaSms" to (request.viaSms ?: false) ) body["successURL"] = request.successUrl?.trim()?.ifBlank { null } ?: buildReturnUrl(propertyId, true) body["failureURL"] = request.failureUrl?.trim()?.ifBlank { null } ?: buildReturnUrl(propertyId, false) if (amountLong != null) { body["subAmount"] = amountLong } if (request.minAmountForCustomer != null) { body["minAmountForCustomer"] = request.minAmountForCustomer } request.expiryDate?.trim()?.ifBlank { null }?.let { body["expiryDate"] = it } val accessToken = resolveAccessToken(settings) settingsRepo.save(settings) val headers = HttpHeaders().apply { contentType = MediaType.APPLICATION_JSON set("Authorization", "Bearer $accessToken") set("merchantId", settings.merchantId) set("mid", settings.merchantId) } val entity = HttpEntity(body, headers) val response = restTemplate.postForEntity(resolveBaseUrl(settings.isTest), entity, String::class.java) val responseBody = response.body ?: "" if (!response.statusCode.is2xxSuccessful) { throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "PayU request failed") } val paymentLink = extractPaymentLink(responseBody) return PayuPaymentLinkCreateResponse( amount = amountLong ?: pending, currency = booking.property.currency, paymentLink = paymentLink, payuResponse = responseBody ) } private fun resolveBaseUrl(isTest: Boolean): String { return if (isTest) { "https://uatoneapi.payu.in/payment-links" } else { "https://oneapi.payu.in/payment-links" } } private fun resolveAccessToken(settings: com.android.trisolarisserver.models.payment.PayuPaymentLinkSettings): String { val now = OffsetDateTime.now() val existing = settings.accessToken?.trim()?.ifBlank { null } val expiresAt = settings.tokenExpiresAt if (existing != null && expiresAt != null && expiresAt.isAfter(now.plusSeconds(60))) { return existing } val clientId = settings.clientId?.trim()?.ifBlank { null } val clientSecret = settings.clientSecret?.trim()?.ifBlank { null } if (clientId == null || clientSecret == null) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Payment link client credentials missing") } val tokenResponse = fetchAccessToken(settings.isTest, clientId, clientSecret) settings.accessToken = tokenResponse.accessToken settings.tokenExpiresAt = now.plusSeconds(tokenResponse.expiresIn.toLong()) return tokenResponse.accessToken } private data class TokenResponse(val accessToken: String, val expiresIn: Int) private fun fetchAccessToken(isTest: Boolean, clientId: String, clientSecret: String): TokenResponse { val url = if (isTest) { "https://uat-accounts.payu.in/oauth/token" } else { "https://accounts.payu.in/oauth/token" } val form = org.springframework.util.LinkedMultiValueMap().apply { add("client_id", clientId) add("client_secret", clientSecret) add("grant_type", "client_credentials") add("scope", "create_payment_links") } val headers = HttpHeaders().apply { contentType = MediaType.APPLICATION_FORM_URLENCODED } val entity = HttpEntity(form, headers) val response = restTemplate.postForEntity(url, entity, String::class.java) val body = response.body ?: "" if (!response.statusCode.is2xxSuccessful) { throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "PayU token request failed") } return try { val node = objectMapper.readTree(body) val token = node.path("access_token").asText(null) val expiresIn = node.path("expires_in").asInt(0) if (token.isNullOrBlank() || expiresIn <= 0) { throw IllegalStateException("Token missing") } TokenResponse(token, expiresIn) } catch (ex: Exception) { throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "PayU token parse failed") } } private fun extractPaymentLink(body: String): String? { if (body.isBlank()) return null return try { val node = objectMapper.readTree(body) val link = node.path("result").path("paymentLink").asText(null) link?.takeIf { it.isNotBlank() } } catch (_: Exception) { null } } 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 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 } }