diff --git a/src/main/kotlin/com/android/trisolarisserver/config/PayuPaymentLinkSettingsSchemaFix.kt b/src/main/kotlin/com/android/trisolarisserver/config/PayuPaymentLinkSettingsSchemaFix.kt new file mode 100644 index 0000000..a15608e --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/config/PayuPaymentLinkSettingsSchemaFix.kt @@ -0,0 +1,36 @@ +package com.android.trisolarisserver.config + +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Component + +@Component +class PayuPaymentLinkSettingsSchemaFix( + private val jdbcTemplate: JdbcTemplate +) : PostgresSchemaFix(jdbcTemplate) { + + override fun runPostgres(jdbcTemplate: JdbcTemplate) { + val hasTable = jdbcTemplate.queryForObject( + """ + select count(*) + from information_schema.tables + where table_name = 'payu_payment_link_settings' + """.trimIndent(), + Int::class.java + ) ?: 0 + if (hasTable == 0) { + logger.info("Creating payu_payment_link_settings table") + jdbcTemplate.execute( + """ + create table payu_payment_link_settings ( + id uuid primary key, + property_id uuid not null unique references property(id) on delete cascade, + merchant_id text not null, + access_token text not null, + is_test boolean not null default false, + updated_at timestamptz not null + ) + """.trimIndent() + ) + } + } +} diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/PayuPaymentLinkSettingsController.kt b/src/main/kotlin/com/android/trisolarisserver/controller/PayuPaymentLinkSettingsController.kt new file mode 100644 index 0000000..bc05777 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/controller/PayuPaymentLinkSettingsController.kt @@ -0,0 +1,96 @@ +package com.android.trisolarisserver.controller + +import com.android.trisolarisserver.component.PropertyAccess +import com.android.trisolarisserver.controller.dto.PayuPaymentLinkSettingsResponse +import com.android.trisolarisserver.controller.dto.PayuPaymentLinkSettingsUpsertRequest +import com.android.trisolarisserver.models.payment.PayuPaymentLinkSettings +import com.android.trisolarisserver.models.property.Role +import com.android.trisolarisserver.repo.PayuPaymentLinkSettingsRepo +import com.android.trisolarisserver.repo.PropertyRepo +import com.android.trisolarisserver.security.MyPrincipal +import org.springframework.http.HttpStatus +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.PutMapping +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.server.ResponseStatusException +import java.time.OffsetDateTime +import java.util.UUID + +@RestController +@RequestMapping("/properties/{propertyId}/payu-payment-link-settings") +class PayuPaymentLinkSettingsController( + private val propertyAccess: PropertyAccess, + private val propertyRepo: PropertyRepo, + private val settingsRepo: PayuPaymentLinkSettingsRepo +) { + + @GetMapping + fun getSettings( + @PathVariable propertyId: UUID, + @AuthenticationPrincipal principal: MyPrincipal? + ): PayuPaymentLinkSettingsResponse { + requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER) + val settings = settingsRepo.findByPropertyId(propertyId) + if (settings == null) { + return PayuPaymentLinkSettingsResponse( + propertyId = propertyId, + configured = false, + merchantId = null, + isTest = false, + hasAccessToken = false + ) + } + return settings.toResponse() + } + + @PutMapping + fun upsertSettings( + @PathVariable propertyId: UUID, + @AuthenticationPrincipal principal: MyPrincipal?, + @RequestBody request: PayuPaymentLinkSettingsUpsertRequest + ): PayuPaymentLinkSettingsResponse { + requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER) + val property = propertyRepo.findById(propertyId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found") + } + val merchantId = request.merchantId.trim().ifBlank { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "merchantId required") + } + val accessToken = request.accessToken.trim().ifBlank { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "accessToken required") + } + val isTest = request.isTest ?: false + val existing = settingsRepo.findByPropertyId(propertyId) + val updated = if (existing == null) { + PayuPaymentLinkSettings( + property = property, + merchantId = merchantId, + accessToken = accessToken, + isTest = isTest, + updatedAt = OffsetDateTime.now() + ) + } else { + existing.merchantId = merchantId + existing.accessToken = accessToken + existing.isTest = isTest + existing.updatedAt = OffsetDateTime.now() + existing + } + return settingsRepo.save(updated).toResponse() + } +} + +private fun PayuPaymentLinkSettings.toResponse(): PayuPaymentLinkSettingsResponse { + val propertyId = property.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Property id missing") + return PayuPaymentLinkSettingsResponse( + propertyId = propertyId, + configured = true, + merchantId = merchantId, + isTest = isTest, + hasAccessToken = accessToken.isNotBlank() + ) +} diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/PayuPaymentLinksController.kt b/src/main/kotlin/com/android/trisolarisserver/controller/PayuPaymentLinksController.kt new file mode 100644 index 0000000..8319eb7 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/controller/PayuPaymentLinksController.kt @@ -0,0 +1,160 @@ +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.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 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.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.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 +) { + + @PostMapping("/link") + 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) + ) + if (amountLong != null) { + body["subAmount"] = amountLong + } + if (request.minAmountForCustomer != null) { + body["minAmountForCustomer"] = request.minAmountForCustomer + } + request.expiryDate?.trim()?.ifBlank { null }?.let { body["expiryDate"] = it } + + val headers = HttpHeaders().apply { + contentType = MediaType.APPLICATION_JSON + set("Authorization", "Bearer ${settings.accessToken}") + set("merchantId", 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") + } + + return PayuPaymentLinkCreateResponse( + amount = amountLong ?: pending, + currency = booking.property.currency, + 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 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 + } +} diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/dto/PayuDtos.kt b/src/main/kotlin/com/android/trisolarisserver/controller/dto/PayuDtos.kt index e94d447..84fe6a0 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/dto/PayuDtos.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/dto/PayuDtos.kt @@ -46,3 +46,37 @@ data class PayuQrGenerateResponse( val currency: String, val payuResponse: String ) + +data class PayuPaymentLinkSettingsUpsertRequest( + val merchantId: String, + val accessToken: String, + val isTest: Boolean? = null +) + +data class PayuPaymentLinkSettingsResponse( + val propertyId: UUID, + val configured: Boolean, + val merchantId: String?, + val isTest: Boolean, + val hasAccessToken: Boolean +) + +data class PayuPaymentLinkCreateRequest( + val amount: Long? = null, + val isAmountFilledByCustomer: Boolean? = null, + val isPartialPaymentAllowed: Boolean? = null, + val minAmountForCustomer: Long? = null, + val description: String? = null, + val expiryDate: String? = null, + val udf3: String? = null, + val udf4: String? = null, + val udf5: String? = null, + val viaEmail: Boolean? = null, + val viaSms: Boolean? = null +) + +data class PayuPaymentLinkCreateResponse( + val amount: Long, + val currency: String, + val payuResponse: String +) diff --git a/src/main/kotlin/com/android/trisolarisserver/models/payment/PayuPaymentLinkSettings.kt b/src/main/kotlin/com/android/trisolarisserver/models/payment/PayuPaymentLinkSettings.kt new file mode 100644 index 0000000..47c7a5e --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/models/payment/PayuPaymentLinkSettings.kt @@ -0,0 +1,42 @@ +package com.android.trisolarisserver.models.payment + +import com.android.trisolarisserver.models.property.Property +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint +import java.time.OffsetDateTime +import java.util.UUID + +@Entity +@Table( + name = "payu_payment_link_settings", + uniqueConstraints = [UniqueConstraint(columnNames = ["property_id"])] +) +class PayuPaymentLinkSettings( + @Id + @GeneratedValue + @Column(columnDefinition = "uuid") + val id: UUID? = null, + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "property_id", nullable = false) + var property: Property, + + @Column(name = "merchant_id", nullable = false, columnDefinition = "text") + var merchantId: String, + + @Column(name = "access_token", nullable = false, columnDefinition = "text") + var accessToken: String, + + @Column(name = "is_test", nullable = false) + var isTest: Boolean = false, + + @Column(name = "updated_at", columnDefinition = "timestamptz") + var updatedAt: OffsetDateTime = OffsetDateTime.now() +) diff --git a/src/main/kotlin/com/android/trisolarisserver/repo/PayuPaymentLinkSettingsRepo.kt b/src/main/kotlin/com/android/trisolarisserver/repo/PayuPaymentLinkSettingsRepo.kt new file mode 100644 index 0000000..6c2bf33 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/repo/PayuPaymentLinkSettingsRepo.kt @@ -0,0 +1,9 @@ +package com.android.trisolarisserver.repo + +import com.android.trisolarisserver.models.payment.PayuPaymentLinkSettings +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface PayuPaymentLinkSettingsRepo : JpaRepository { + fun findByPropertyId(propertyId: UUID): PayuPaymentLinkSettings? +}