245 lines
11 KiB
Kotlin
245 lines
11 KiB
Kotlin
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<String, Any>(
|
|
"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<String, String>().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<com.android.trisolarisserver.models.room.RoomStay>, 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
|
|
}
|
|
}
|