Replace PayU integration with Razorpay
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s

This commit is contained in:
androidlover5842
2026-02-01 09:44:57 +05:30
parent 93ac0dbc9e
commit ebaef53f98
38 changed files with 935 additions and 1421 deletions

View File

@@ -1,61 +0,0 @@
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,
client_id text,
client_secret text,
access_token text,
token_expires_at timestamptz,
is_test boolean not null default false,
updated_at timestamptz not null
)
""".trimIndent()
)
}
ensureColumn("payu_payment_link_settings", "client_id", "text")
ensureColumn("payu_payment_link_settings", "client_secret", "text")
ensureColumn("payu_payment_link_settings", "access_token", "text")
ensureColumn("payu_payment_link_settings", "token_expires_at", "timestamptz")
jdbcTemplate.execute("alter table payu_payment_link_settings alter column access_token drop not null")
}
private fun ensureColumn(table: String, column: String, type: String) {
val exists = jdbcTemplate.queryForObject(
"""
select count(*)
from information_schema.columns
where table_name = '$table'
and column_name = '$column'
""".trimIndent(),
Int::class.java
) ?: 0
if (exists == 0) {
logger.info("Adding $table.$column column")
jdbcTemplate.execute("alter table $table add column $column $type")
}
}
}

View File

@@ -1,58 +0,0 @@
package com.android.trisolarisserver.config
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Component
@Component
class PayuSettingsSchemaFix(
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_settings'
""".trimIndent(),
Int::class.java
) ?: 0
if (hasTable == 0) {
logger.info("Creating payu_settings table")
jdbcTemplate.execute(
"""
create table payu_settings (
id uuid primary key,
property_id uuid not null unique references property(id) on delete cascade,
merchant_key varchar not null,
salt_32 varchar,
salt_256 varchar,
base_url varchar not null,
is_test boolean not null default false,
use_salt_256 boolean not null default true,
updated_at timestamptz not null
)
""".trimIndent()
)
}
val hasIsTest = jdbcTemplate.queryForObject(
"""
select count(*)
from information_schema.columns
where table_name = 'payu_settings'
and column_name = 'is_test'
""".trimIndent(),
Int::class.java
) ?: 0
if (hasIsTest == 0) {
logger.info("Adding payu_settings.is_test column")
jdbcTemplate.execute("alter table payu_settings add column is_test boolean not null default false")
}
logger.info("Ensuring payu_settings text column sizes")
jdbcTemplate.execute("alter table payu_settings alter column merchant_key type text")
jdbcTemplate.execute("alter table payu_settings alter column salt_32 type text")
jdbcTemplate.execute("alter table payu_settings alter column salt_256 type text")
jdbcTemplate.execute("alter table payu_settings alter column base_url type text")
}
}

View File

@@ -4,7 +4,7 @@ import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Component
@Component
class PayuPaymentAttemptSchemaFix(
class RazorpayPaymentAttemptSchemaFix(
private val jdbcTemplate: JdbcTemplate
) : PostgresSchemaFix(jdbcTemplate) {
@@ -13,32 +13,24 @@ class PayuPaymentAttemptSchemaFix(
"""
select count(*)
from information_schema.tables
where table_name = 'payu_payment_attempt'
where table_name = 'razorpay_payment_attempt'
""".trimIndent(),
Int::class.java
) ?: 0
if (hasTable == 0) {
logger.info("Creating payu_payment_attempt table")
logger.info("Creating razorpay_payment_attempt table")
jdbcTemplate.execute(
"""
create table payu_payment_attempt (
create table razorpay_payment_attempt (
id uuid primary key,
property_id uuid not null references property(id) on delete cascade,
booking_id uuid references booking(id) on delete set null,
event varchar,
status varchar,
unmapped_status varchar,
amount bigint,
currency varchar,
gateway_payment_id varchar,
gateway_txn_id varchar,
bank_ref_num varchar,
mode varchar,
pg_type varchar,
payer_vpa varchar,
payer_name varchar,
payment_source varchar,
error_code varchar,
error_message varchar,
payment_id varchar,
order_id varchar,
payload text,
received_at timestamptz not null
)

View File

@@ -0,0 +1,41 @@
package com.android.trisolarisserver.config
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Component
@Component
class RazorpayPaymentLinkRequestSchemaFix(
private val jdbcTemplate: JdbcTemplate
) : PostgresSchemaFix(jdbcTemplate) {
override fun runPostgres(jdbcTemplate: JdbcTemplate) {
val hasTable = jdbcTemplate.queryForObject(
"""
select count(*)
from information_schema.tables
where table_name = 'razorpay_payment_link_request'
""".trimIndent(),
Int::class.java
) ?: 0
if (hasTable == 0) {
logger.info("Creating razorpay_payment_link_request table")
jdbcTemplate.execute(
"""
create table razorpay_payment_link_request (
id uuid primary key,
property_id uuid not null references property(id) on delete cascade,
booking_id uuid not null references booking(id) on delete cascade,
payment_link_id varchar,
amount bigint not null,
currency varchar not null,
status varchar not null,
short_url text,
request_payload text,
response_payload text,
created_at timestamptz not null
)
""".trimIndent()
)
}
}
}

View File

@@ -4,7 +4,7 @@ import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Component
@Component
class PayuQrRequestSchemaFix(
class RazorpayQrRequestSchemaFix(
private val jdbcTemplate: JdbcTemplate
) : PostgresSchemaFix(jdbcTemplate) {
@@ -13,22 +13,23 @@ class PayuQrRequestSchemaFix(
"""
select count(*)
from information_schema.tables
where table_name = 'payu_qr_request'
where table_name = 'razorpay_qr_request'
""".trimIndent(),
Int::class.java
) ?: 0
if (hasTable == 0) {
logger.info("Creating payu_qr_request table")
logger.info("Creating razorpay_qr_request table")
jdbcTemplate.execute(
"""
create table payu_qr_request (
create table razorpay_qr_request (
id uuid primary key,
property_id uuid not null references property(id) on delete cascade,
booking_id uuid not null references booking(id) on delete cascade,
txnid varchar not null,
qr_id varchar,
amount bigint not null,
currency varchar not null,
status varchar not null,
image_url text,
request_payload text,
response_payload text,
expiry_at timestamptz,
@@ -36,25 +37,6 @@ class PayuQrRequestSchemaFix(
)
""".trimIndent()
)
} else {
val hasExpiryAt = jdbcTemplate.queryForObject(
"""
select count(*)
from information_schema.columns
where table_name = 'payu_qr_request'
and column_name = 'expiry_at'
""".trimIndent(),
Int::class.java
) ?: 0
if (hasExpiryAt == 0) {
logger.info("Adding expiry_at to payu_qr_request table")
jdbcTemplate.execute(
"""
alter table payu_qr_request
add column expiry_at timestamptz
""".trimIndent()
)
}
}
}
}

View File

@@ -0,0 +1,41 @@
package com.android.trisolarisserver.config
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Component
@Component
class RazorpaySettingsSchemaFix(
private val jdbcTemplate: JdbcTemplate
) : PostgresSchemaFix(jdbcTemplate) {
override fun runPostgres(jdbcTemplate: JdbcTemplate) {
val hasTable = jdbcTemplate.queryForObject(
"""
select count(*)
from information_schema.tables
where table_name = 'razorpay_settings'
""".trimIndent(),
Int::class.java
) ?: 0
if (hasTable == 0) {
logger.info("Creating razorpay_settings table")
jdbcTemplate.execute(
"""
create table razorpay_settings (
id uuid primary key,
property_id uuid not null unique references property(id) on delete cascade,
key_id varchar not null,
key_secret varchar not null,
webhook_secret varchar,
is_test boolean not null default false,
updated_at timestamptz not null
)
""".trimIndent()
)
}
logger.info("Ensuring razorpay_settings text column sizes")
jdbcTemplate.execute("alter table razorpay_settings alter column key_id type text")
jdbcTemplate.execute("alter table razorpay_settings alter column key_secret type text")
jdbcTemplate.execute("alter table razorpay_settings alter column webhook_secret type text")
}
}

View File

@@ -4,7 +4,7 @@ import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Component
@Component
class PayuWebhookLogSchemaFix(
class RazorpayWebhookLogSchemaFix(
private val jdbcTemplate: JdbcTemplate
) : PostgresSchemaFix(jdbcTemplate) {
@@ -13,15 +13,15 @@ class PayuWebhookLogSchemaFix(
"""
select count(*)
from information_schema.tables
where table_name = 'payu_webhook_log'
where table_name = 'razorpay_webhook_log'
""".trimIndent(),
Int::class.java
) ?: 0
if (hasTable == 0) {
logger.info("Creating payu_webhook_log table")
logger.info("Creating razorpay_webhook_log table")
jdbcTemplate.execute(
"""
create table payu_webhook_log (
create table razorpay_webhook_log (
id uuid primary key,
property_id uuid not null references property(id) on delete cascade,
headers text,

View File

@@ -1,111 +0,0 @@
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,
hasClientId = false,
hasClientSecret = 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 isTest = request.isTest ?: false
val existing = settingsRepo.findByPropertyId(propertyId)
val updated = if (existing == null) {
PayuPaymentLinkSettings(
property = property,
merchantId = merchantId,
clientId = request.clientId?.trim()?.ifBlank { null },
clientSecret = request.clientSecret?.trim()?.ifBlank { null },
accessToken = request.accessToken?.trim()?.ifBlank { null },
isTest = isTest,
updatedAt = OffsetDateTime.now()
)
} else {
existing.merchantId = merchantId
val oldClientId = existing.clientId
val oldClientSecret = existing.clientSecret
val oldIsTest = existing.isTest
if (request.clientId != null) existing.clientId = request.clientId.trim().ifBlank { null }
if (request.clientSecret != null) existing.clientSecret = request.clientSecret.trim().ifBlank { null }
if (request.accessToken != null) existing.accessToken = request.accessToken.trim().ifBlank { null }
existing.isTest = isTest
val credsChanged = (request.clientId != null && existing.clientId != oldClientId) ||
(request.clientSecret != null && existing.clientSecret != oldClientSecret) ||
oldIsTest != isTest
if (credsChanged) {
existing.accessToken = null
existing.tokenExpiresAt = null
}
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,
hasClientId = !clientId.isNullOrBlank(),
hasClientSecret = !clientSecret.isNullOrBlank(),
hasAccessToken = !accessToken.isNullOrBlank()
)
}

View File

@@ -1,217 +0,0 @@
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)
)
request.successUrl?.trim()?.ifBlank { null }?.let { body["successURL"] = it }
request.failureUrl?.trim()?.ifBlank { null }?.let { body["failureURL"] = it }
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
}
}
}

View File

@@ -1,260 +0,0 @@
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<String, String>().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 }
}
}

View File

@@ -1,108 +0,0 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.controller.dto.PayuSettingsUpsertRequest
import com.android.trisolarisserver.controller.dto.PayuSettingsResponse
import com.android.trisolarisserver.models.payment.PayuSettings
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.PayuSettingsRepo
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-settings")
class PayuSettingsController(
private val propertyAccess: PropertyAccess,
private val propertyRepo: PropertyRepo,
private val payuSettingsRepo: PayuSettingsRepo
) {
@GetMapping
fun getSettings(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): PayuSettingsResponse {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
val settings = payuSettingsRepo.findByPropertyId(propertyId)
if (settings == null) {
return PayuSettingsResponse(
propertyId = propertyId,
configured = false,
merchantKey = null,
isTest = false,
useSalt256 = true,
hasSalt32 = false,
hasSalt256 = false
)
}
return settings.toResponse()
}
@PutMapping
fun upsertSettings(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: PayuSettingsUpsertRequest
): PayuSettingsResponse {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val key = request.merchantKey.trim().ifBlank {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "merchantKey required")
}
val isTest = request.isTest ?: false
val baseUrl = if (isTest) {
"https://test.payu.in/_payment"
} else {
"https://secure.payu.in/_payment"
}
val existing = payuSettingsRepo.findByPropertyId(propertyId)
val updated = if (existing == null) {
PayuSettings(
property = property,
merchantKey = key,
salt32 = request.salt32?.trim()?.ifBlank { null },
salt256 = request.salt256?.trim()?.ifBlank { null },
baseUrl = baseUrl,
isTest = isTest,
useSalt256 = request.useSalt256 ?: true,
updatedAt = OffsetDateTime.now()
)
} else {
existing.merchantKey = key
if (request.salt32 != null) existing.salt32 = request.salt32.trim().ifBlank { null }
if (request.salt256 != null) existing.salt256 = request.salt256.trim().ifBlank { null }
existing.baseUrl = baseUrl
existing.isTest = isTest
if (request.useSalt256 != null) existing.useSalt256 = request.useSalt256
existing.updatedAt = OffsetDateTime.now()
existing
}
return payuSettingsRepo.save(updated).toResponse()
}
}
private fun PayuSettings.toResponse(): PayuSettingsResponse {
val propertyId = property.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Property id missing")
return PayuSettingsResponse(
propertyId = propertyId,
configured = true,
merchantKey = merchantKey,
isTest = isTest,
useSalt256 = useSalt256,
hasSalt32 = !salt32.isNullOrBlank(),
hasSalt256 = !salt256.isNullOrBlank()
)
}

View File

@@ -1,181 +0,0 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.models.booking.Payment
import com.android.trisolarisserver.models.booking.PaymentMethod
import com.android.trisolarisserver.models.payment.PayuPaymentAttempt
import com.android.trisolarisserver.models.payment.PayuWebhookLog
import com.android.trisolarisserver.db.repo.BookingRepo
import com.android.trisolarisserver.repo.PayuPaymentAttemptRepo
import com.android.trisolarisserver.repo.PayuWebhookLogRepo
import com.android.trisolarisserver.repo.PaymentRepo
import com.android.trisolarisserver.repo.PropertyRepo
import jakarta.servlet.http.HttpServletRequest
import org.springframework.http.HttpStatus
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.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import java.net.URLDecoder
import java.time.OffsetDateTime
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/payu/webhook")
class PayuWebhookCapture(
private val propertyRepo: PropertyRepo,
private val bookingRepo: BookingRepo,
private val paymentRepo: PaymentRepo,
private val payuPaymentAttemptRepo: PayuPaymentAttemptRepo,
private val payuWebhookLogRepo: PayuWebhookLogRepo
) {
@PostMapping
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional
fun capture(
@PathVariable propertyId: UUID,
@RequestBody(required = false) body: String?,
request: HttpServletRequest
) {
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val headers = request.headerNames.toList().associateWith { request.getHeader(it) }
val headersText = headers.entries.joinToString("\n") { (k, v) -> "$k: $v" }
payuWebhookLogRepo.save(
PayuWebhookLog(
property = property,
headers = headersText,
payload = body,
contentType = request.contentType,
receivedAt = OffsetDateTime.now()
)
)
if (body.isNullOrBlank()) return
val data = parseFormBody(body)
val status = data["status"]?.lowercase() ?: data["unmappedstatus"]?.lowercase()
val isSuccess = status == "success" || status == "captured"
val isRefund = status == "refund" || status == "refunded"
val bookingId = data["udf1"]?.let { runCatching { UUID.fromString(it) }.getOrNull() }
val booking = bookingId?.let { bookingRepo.findById(it).orElse(null) }
if (booking != null && booking.property.id != propertyId) return
val amountRaw = data["amount"]?.ifBlank { null } ?: data["net_amount_debit"]?.ifBlank { null }
val amount = parseAmount(amountRaw)
val gatewayPaymentId = data["mihpayid"]?.ifBlank { null }
val gatewayTxnId = data["txnid"]?.ifBlank { null }
val bankRef = data["bank_ref_num"]?.ifBlank { null } ?: data["bank_ref_no"]?.ifBlank { null }
val mode = data["mode"]?.ifBlank { null }
val pgType = data["PG_TYPE"]?.ifBlank { null }
val payerVpa = data["field3"]?.ifBlank { null }
val payerName = data["field6"]?.ifBlank { null }
val paymentSource = data["payment_source"]?.ifBlank { null }
val errorCode = data["error"]?.ifBlank { null }
val errorMessage = data["error_Message"]?.ifBlank { null }
val receivedAt = parseAddedOn(data["addedon"], booking?.property?.timezone)
payuPaymentAttemptRepo.save(
PayuPaymentAttempt(
property = property,
booking = booking,
status = status,
unmappedStatus = data["unmappedstatus"]?.ifBlank { null },
amount = amount,
currency = booking?.property?.currency ?: property.currency,
gatewayPaymentId = gatewayPaymentId,
gatewayTxnId = gatewayTxnId,
bankRefNum = bankRef,
mode = mode,
pgType = pgType,
payerVpa = payerVpa,
payerName = payerName,
paymentSource = paymentSource,
errorCode = errorCode,
errorMessage = errorMessage,
payload = body,
receivedAt = receivedAt
)
)
if (!isSuccess && !isRefund) return
if (booking == null) return
if (gatewayPaymentId != null && paymentRepo.findByGatewayPaymentId(gatewayPaymentId) != null) return
if (gatewayPaymentId == null && gatewayTxnId != null && paymentRepo.findByGatewayTxnId(gatewayTxnId) != null) return
val signedAmount = amount?.let { if (isRefund) -it else it } ?: return
val notes = buildString {
append("payu status=").append(status)
gatewayTxnId?.let { append(" txnid=").append(it) }
bankRef?.let { append(" bank_ref=").append(it) }
}
paymentRepo.save(
Payment(
property = booking.property,
booking = booking,
amount = signedAmount,
currency = booking.property.currency,
method = PaymentMethod.ONLINE,
gatewayPaymentId = gatewayPaymentId,
gatewayTxnId = gatewayTxnId,
bankRefNum = bankRef,
mode = mode,
pgType = pgType,
payerVpa = payerVpa,
payerName = payerName,
paymentSource = paymentSource,
reference = gatewayPaymentId?.let { "payu:$it" } ?: gatewayTxnId?.let { "payu:$it" },
notes = notes,
receivedAt = receivedAt
)
)
}
private fun parseFormBody(body: String): Map<String, String> {
return body.split("&")
.mapNotNull { pair ->
val idx = pair.indexOf("=")
if (idx <= 0) return@mapNotNull null
val key = URLDecoder.decode(pair.substring(0, idx), "UTF-8")
val value = URLDecoder.decode(pair.substring(idx + 1), "UTF-8")
key to value
}
.toMap()
}
private fun parseAmount(value: String?): Long? {
if (value.isNullOrBlank()) return null
return try {
val bd = BigDecimal(value.trim()).setScale(0, java.math.RoundingMode.HALF_UP)
bd.longValueExact()
} catch (_: Exception) {
null
}
}
private fun parseAddedOn(value: String?, timezone: String?): OffsetDateTime {
if (value.isNullOrBlank()) return OffsetDateTime.now()
return try {
val local = LocalDateTime.parse(value.trim(), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
val zone = try {
if (timezone.isNullOrBlank()) ZoneId.of("Asia/Kolkata") else ZoneId.of(timezone)
} catch (_: Exception) {
ZoneId.of("Asia/Kolkata")
}
local.atZone(zone).toOffsetDateTime()
} catch (_: Exception) {
OffsetDateTime.now(ZoneOffset.UTC)
}
}
}

View File

@@ -0,0 +1,172 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.controller.dto.RazorpayPaymentLinkCreateRequest
import com.android.trisolarisserver.controller.dto.RazorpayPaymentLinkCreateResponse
import com.android.trisolarisserver.models.booking.BookingStatus
import com.android.trisolarisserver.models.payment.RazorpayPaymentLinkRequest
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.db.repo.BookingRepo
import com.android.trisolarisserver.repo.RazorpayPaymentLinkRequestRepo
import com.android.trisolarisserver.repo.RazorpaySettingsRepo
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.HttpMethod
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
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 java.time.OffsetDateTime
import java.util.Base64
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/bookings/{bookingId}/payments/razorpay")
class RazorpayPaymentLinksController(
private val propertyAccess: PropertyAccess,
private val bookingRepo: BookingRepo,
private val settingsRepo: RazorpaySettingsRepo,
private val linkRequestRepo: RazorpayPaymentLinkRequestRepo,
private val restTemplate: RestTemplate,
private val objectMapper: ObjectMapper
) {
@PostMapping("/payment-link")
fun createPaymentLink(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@RequestBody request: RazorpayPaymentLinkCreateRequest,
principal: MyPrincipal?
): RazorpayPaymentLinkCreateResponse {
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 = linkRequestRepo.findTopByBookingIdAndAmountAndCurrencyAndStatusOrderByCreatedAtDesc(
bookingId,
amount,
currency,
"created"
)
if (existing != null && existing.paymentLinkId != null) {
return RazorpayPaymentLinkCreateResponse(
amount = existing.amount,
currency = existing.currency,
paymentLink = existing.shortUrl,
razorpayResponse = existing.responsePayload ?: "{}"
)
}
val notes = mapOf(
"bookingId" to bookingId.toString(),
"propertyId" to propertyId.toString()
)
val payload = linkedMapOf<String, Any>(
"amount" to amount * 100,
"currency" to currency,
"description" to (request.description ?: "Booking $bookingId"),
"notes" to notes,
"reference_id" to bookingId.toString()
)
parseExpiryEpoch(request.expiryDate)?.let { payload["expire_by"] = it }
request.isPartialPaymentAllowed?.let { payload["partial_payment"] = it }
request.minAmountForCustomer?.let { payload["first_min_partial_amount"] = it * 100 }
request.successUrl?.let { payload["callback_url"] = it }
if (payload["callback_url"] == null && request.failureUrl != null) {
payload["callback_url"] = request.failureUrl
}
val notify = linkedMapOf<String, Any>()
request.viaEmail?.let { notify["email"] = it }
request.viaSms?.let { notify["sms"] = it }
if (notify.isNotEmpty()) {
payload["notify"] = notify
}
val requestPayload = objectMapper.writeValueAsString(payload)
val response = postJson(resolveBaseUrl(settings.isTest) + "/payment_links", settings, requestPayload)
val body = response.body ?: "{}"
val node = objectMapper.readTree(body)
val linkId = node.path("id").asText(null)
val status = node.path("status").asText("unknown")
val shortUrl = node.path("short_url").asText(null)
val record = linkRequestRepo.save(
RazorpayPaymentLinkRequest(
property = booking.property,
booking = booking,
paymentLinkId = linkId,
amount = amount,
currency = currency,
status = status,
shortUrl = shortUrl,
requestPayload = requestPayload,
responsePayload = body,
createdAt = OffsetDateTime.now()
)
)
if (!response.statusCode.is2xxSuccessful) {
record.status = "failed"
linkRequestRepo.save(record)
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "Razorpay request failed")
}
return RazorpayPaymentLinkCreateResponse(
amount = amount,
currency = currency,
paymentLink = shortUrl,
razorpayResponse = body
)
}
private fun postJson(url: String, settings: com.android.trisolarisserver.models.payment.RazorpaySettings, json: String): ResponseEntity<String> {
val headers = HttpHeaders()
headers.contentType = MediaType.APPLICATION_JSON
headers.set(HttpHeaders.AUTHORIZATION, basicAuth(settings.keyId, settings.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 parseExpiryEpoch(value: String?): Long? {
if (value.isNullOrBlank()) return null
return value.trim().toLongOrNull()
}
}

View File

@@ -0,0 +1,162 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.controller.dto.RazorpayQrGenerateRequest
import com.android.trisolarisserver.controller.dto.RazorpayQrGenerateResponse
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.security.MyPrincipal
import com.fasterxml.jackson.databind.ObjectMapper
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.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 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 restTemplate: RestTemplate,
private val objectMapper: ObjectMapper
) {
@PostMapping("/qr")
fun createQr(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@RequestBody request: RazorpayQrGenerateRequest,
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,
razorpayResponse = existing.responsePayload ?: "{}"
)
}
val expirySeconds = request.expirySeconds ?: request.expiryMinutes?.let { it * 60 }
val expiresAt = expirySeconds?.let { OffsetDateTime.now().plusSeconds(it.toLong()) }
val notes = mapOf(
"bookingId" to bookingId.toString(),
"propertyId" to propertyId.toString()
)
val payload = linkedMapOf<String, Any>(
"type" to "upi_qr",
"name" to "Booking $bookingId",
"usage" to "single_use",
"fixed_amount" to true,
"payment_amount" to amount * 100,
"notes" to notes
)
if (expirySeconds != null) {
payload["close_by"] = OffsetDateTime.now().plusSeconds(expirySeconds.toLong()).toEpochSecond()
}
val requestPayload = objectMapper.writeValueAsString(payload)
val response = postJson(resolveBaseUrl(settings.isTest) + "/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,
razorpayResponse = body
)
}
private fun postJson(url: String, settings: com.android.trisolarisserver.models.payment.RazorpaySettings, json: String): ResponseEntity<String> {
val headers = HttpHeaders()
headers.contentType = MediaType.APPLICATION_JSON
headers.set(HttpHeaders.AUTHORIZATION, basicAuth(settings.keyId, settings.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"
}
}

View File

@@ -1,7 +1,6 @@
package com.android.trisolarisserver.controller
import org.springframework.http.HttpStatus
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.RequestMapping
@@ -10,30 +9,17 @@ import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/payu/return")
class PayuReturnController {
@GetMapping("/success")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun success(@PathVariable propertyId: UUID) {
// PayU redirect target; no-op.
}
@RequestMapping("/properties/{propertyId}/razorpay/return")
class RazorpayReturnController {
@PostMapping("/success")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun successPost(@PathVariable propertyId: UUID) {
// PayU redirect target; no-op.
}
@GetMapping("/failure")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun failure(@PathVariable propertyId: UUID) {
// PayU redirect target; no-op.
fun success(@PathVariable propertyId: UUID) {
// Razorpay redirect target; no-op.
}
@PostMapping("/failure")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun failurePost(@PathVariable propertyId: UUID) {
// PayU redirect target; no-op.
fun failure(@PathVariable propertyId: UUID) {
// Razorpay redirect target; no-op.
}
}

View File

@@ -0,0 +1,92 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.controller.dto.RazorpaySettingsResponse
import com.android.trisolarisserver.controller.dto.RazorpaySettingsUpsertRequest
import com.android.trisolarisserver.models.payment.RazorpaySettings
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.repo.RazorpaySettingsRepo
import com.android.trisolarisserver.security.MyPrincipal
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 org.springframework.http.HttpStatus
import java.time.OffsetDateTime
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/razorpay-settings")
class RazorpaySettingsController(
private val propertyAccess: PropertyAccess,
private val propertyRepo: PropertyRepo,
private val settingsRepo: RazorpaySettingsRepo
) {
@GetMapping
fun getSettings(
@PathVariable propertyId: UUID,
principal: MyPrincipal?
): RazorpaySettingsResponse {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
val settings = settingsRepo.findByPropertyId(propertyId)
return if (settings == null) {
RazorpaySettingsResponse(
propertyId = propertyId,
configured = false,
keyId = null,
isTest = false,
hasKeySecret = false,
hasWebhookSecret = false
)
} else {
settings.toResponse()
}
}
@PutMapping
fun upsertSettings(
@PathVariable propertyId: UUID,
principal: MyPrincipal?,
@RequestBody request: RazorpaySettingsUpsertRequest
): RazorpaySettingsResponse {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN)
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val existing = settingsRepo.findByPropertyId(propertyId)
val updated = if (existing == null) {
RazorpaySettings(
property = property,
keyId = request.keyId.trim(),
keySecret = request.keySecret.trim(),
webhookSecret = request.webhookSecret?.trim(),
isTest = request.isTest ?: false,
updatedAt = OffsetDateTime.now()
)
} else {
existing.keyId = request.keyId.trim()
existing.keySecret = request.keySecret.trim()
existing.webhookSecret = request.webhookSecret?.trim()
request.isTest?.let { existing.isTest = it }
existing.updatedAt = OffsetDateTime.now()
existing
}
return settingsRepo.save(updated).toResponse()
}
}
private fun RazorpaySettings.toResponse(): RazorpaySettingsResponse {
return RazorpaySettingsResponse(
propertyId = property.id!!,
configured = true,
keyId = keyId,
isTest = isTest,
hasKeySecret = keySecret.isNotBlank(),
hasWebhookSecret = !webhookSecret.isNullOrBlank()
)
}

View File

@@ -0,0 +1,144 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.db.repo.BookingRepo
import com.android.trisolarisserver.models.booking.Payment
import com.android.trisolarisserver.models.booking.PaymentMethod
import com.android.trisolarisserver.models.payment.RazorpayPaymentAttempt
import com.android.trisolarisserver.models.payment.RazorpayWebhookLog
import com.android.trisolarisserver.repo.PaymentRepo
import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.repo.RazorpayPaymentAttemptRepo
import com.android.trisolarisserver.repo.RazorpaySettingsRepo
import com.android.trisolarisserver.repo.RazorpayWebhookLogRepo
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.servlet.http.HttpServletRequest
import org.springframework.http.HttpStatus
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.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import java.time.OffsetDateTime
import java.util.UUID
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
@RestController
@RequestMapping("/properties/{propertyId}/razorpay/webhook")
class RazorpayWebhookCapture(
private val propertyRepo: PropertyRepo,
private val bookingRepo: BookingRepo,
private val paymentRepo: PaymentRepo,
private val settingsRepo: RazorpaySettingsRepo,
private val razorpayPaymentAttemptRepo: RazorpayPaymentAttemptRepo,
private val razorpayWebhookLogRepo: RazorpayWebhookLogRepo,
private val objectMapper: ObjectMapper
) {
@PostMapping
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional
fun capture(
@PathVariable propertyId: UUID,
@RequestBody(required = false) body: String?,
request: HttpServletRequest
) {
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val settings = settingsRepo.findByPropertyId(propertyId)
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay settings not configured")
val headers = request.headerNames.toList().associateWith { request.getHeader(it) }
val headersText = headers.entries.joinToString("\n") { (k, v) -> "$k: $v" }
razorpayWebhookLogRepo.save(
RazorpayWebhookLog(
property = property,
headers = headersText,
payload = body,
contentType = request.contentType,
receivedAt = OffsetDateTime.now()
)
)
if (body.isNullOrBlank()) return
val signature = request.getHeader("X-Razorpay-Signature")
?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing signature")
val secret = settings.webhookSecret
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Webhook secret not configured")
if (!verifySignature(body, secret, signature)) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid signature")
}
val root = objectMapper.readTree(body)
val event = root.path("event").asText(null)
val paymentEntity = root.path("payload").path("payment").path("entity")
val orderEntity = root.path("payload").path("order").path("entity")
val paymentId = paymentEntity.path("id").asText(null)
val orderId = paymentEntity.path("order_id").asText(null).ifBlank { null }
?: orderEntity.path("id").asText(null)
val status = paymentEntity.path("status").asText(null)
val amountPaise = paymentEntity.path("amount").asLong(0)
val currency = paymentEntity.path("currency").asText(property.currency)
val notes = paymentEntity.path("notes")
val bookingId = notes.path("bookingId").asText(null)?.let { runCatching { UUID.fromString(it) }.getOrNull() }
?: orderEntity.path("receipt").asText(null)?.let { runCatching { UUID.fromString(it) }.getOrNull() }
val booking = bookingId?.let { bookingRepo.findById(it).orElse(null) }
if (booking != null && booking.property.id != propertyId) return
razorpayPaymentAttemptRepo.save(
RazorpayPaymentAttempt(
property = property,
booking = booking,
event = event,
status = status,
amount = paiseToAmount(amountPaise),
currency = currency,
paymentId = paymentId,
orderId = orderId,
payload = body,
receivedAt = OffsetDateTime.now()
)
)
if (event == null || paymentId == null || booking == null) return
if (event != "payment.captured" && event != "refund.processed") return
if (paymentRepo.findByGatewayPaymentId(paymentId) != null) return
val signedAmount = if (event == "refund.processed") -paiseToAmount(amountPaise) else paiseToAmount(amountPaise)
val notesText = "razorpay event=$event status=$status order_id=$orderId"
paymentRepo.save(
Payment(
property = booking.property,
booking = booking,
amount = signedAmount,
currency = booking.property.currency,
method = PaymentMethod.ONLINE,
gatewayPaymentId = paymentId,
gatewayTxnId = orderId,
reference = "razorpay:$paymentId",
notes = notesText,
receivedAt = OffsetDateTime.now()
)
)
}
private fun verifySignature(payload: String, secret: String, signature: String): Boolean {
return try {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(secret.toByteArray(), "HmacSHA256"))
val hash = mac.doFinal(payload.toByteArray()).joinToString("") { "%02x".format(it) }
hash.equals(signature, ignoreCase = true)
} catch (_: Exception) {
false
}
}
private fun paiseToAmount(paise: Long): Long {
return paise / 100
}
}

View File

@@ -1,89 +0,0 @@
package com.android.trisolarisserver.controller.dto
import java.util.UUID
data class PayuSettingsUpsertRequest(
val merchantKey: String,
val salt32: String? = null,
val salt256: String? = null,
val isTest: Boolean? = null,
val useSalt256: Boolean? = null
)
data class PayuSettingsResponse(
val propertyId: UUID,
val configured: Boolean,
val merchantKey: String?,
val isTest: Boolean,
val useSalt256: Boolean,
val hasSalt32: Boolean,
val hasSalt256: Boolean
)
data class PayuQrGenerateRequest(
val amount: Long? = null,
val customerName: String? = null,
val customerEmail: String? = null,
val customerPhone: String? = null,
val expiryMinutes: Int? = null,
val expirySeconds: Int? = null,
val clientIp: String? = null,
val deviceInfo: String? = null,
val address1: String? = null,
val address2: String? = null,
val city: String? = null,
val state: String? = null,
val country: String? = null,
val zipcode: String? = null,
val udf3: String? = null,
val udf4: String? = null,
val udf5: String? = null
)
data class PayuQrGenerateResponse(
val txnid: String,
val amount: Long,
val currency: String,
val payuResponse: String
)
data class PayuPaymentLinkSettingsUpsertRequest(
val merchantId: String,
val clientId: String? = null,
val clientSecret: String? = null,
val accessToken: String? = null,
val isTest: Boolean? = null
)
data class PayuPaymentLinkSettingsResponse(
val propertyId: UUID,
val configured: Boolean,
val merchantId: String?,
val isTest: Boolean,
val hasClientId: Boolean,
val hasClientSecret: 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 successUrl: String? = null,
val failureUrl: 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 paymentLink: String?,
val payuResponse: String
)

View File

@@ -0,0 +1,55 @@
package com.android.trisolarisserver.controller.dto
import java.util.UUID
data class RazorpaySettingsUpsertRequest(
val keyId: String,
val keySecret: String,
val webhookSecret: String? = null,
val isTest: Boolean? = null
)
data class RazorpaySettingsResponse(
val propertyId: UUID,
val configured: Boolean,
val keyId: String?,
val isTest: Boolean,
val hasKeySecret: Boolean,
val hasWebhookSecret: Boolean
)
data class RazorpayQrGenerateRequest(
val amount: Long? = null,
val customerName: String? = null,
val customerEmail: String? = null,
val customerPhone: String? = null,
val expiryMinutes: Int? = null,
val expirySeconds: Int? = null
)
data class RazorpayQrGenerateResponse(
val qrId: String?,
val amount: Long,
val currency: String,
val imageUrl: String?,
val razorpayResponse: String
)
data class RazorpayPaymentLinkCreateRequest(
val amount: Long? = null,
val isPartialPaymentAllowed: Boolean? = null,
val minAmountForCustomer: Long? = null,
val description: String? = null,
val expiryDate: String? = null,
val successUrl: String? = null,
val failureUrl: String? = null,
val viaEmail: Boolean? = null,
val viaSms: Boolean? = null
)
data class RazorpayPaymentLinkCreateResponse(
val amount: Long,
val currency: String,
val paymentLink: String?,
val razorpayResponse: String
)

View File

@@ -1,79 +0,0 @@
package com.android.trisolarisserver.models.payment
import com.android.trisolarisserver.models.booking.Booking
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 java.time.OffsetDateTime
import java.util.UUID
@Entity
@Table(name = "payu_payment_attempt")
class PayuPaymentAttempt(
@Id
@GeneratedValue
@Column(columnDefinition = "uuid")
val id: UUID? = null,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "property_id", nullable = false)
var property: Property,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "booking_id")
var booking: Booking? = null,
@Column(name = "status")
var status: String? = null,
@Column(name = "unmapped_status")
var unmappedStatus: String? = null,
@Column(name = "amount")
var amount: Long? = null,
@Column(name = "currency")
var currency: String? = null,
@Column(name = "gateway_payment_id")
var gatewayPaymentId: String? = null,
@Column(name = "gateway_txn_id")
var gatewayTxnId: String? = null,
@Column(name = "bank_ref_num")
var bankRefNum: String? = null,
@Column(name = "mode")
var mode: String? = null,
@Column(name = "pg_type")
var pgType: String? = null,
@Column(name = "payer_vpa")
var payerVpa: String? = null,
@Column(name = "payer_name")
var payerName: String? = null,
@Column(name = "payment_source")
var paymentSource: String? = null,
@Column(name = "error_code")
var errorCode: String? = null,
@Column(name = "error_message")
var errorMessage: String? = null,
@Column(name = "payload", columnDefinition = "text")
var payload: String? = null,
@Column(name = "received_at", columnDefinition = "timestamptz")
var receivedAt: OffsetDateTime = OffsetDateTime.now()
)

View File

@@ -1,51 +0,0 @@
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 = "client_id", columnDefinition = "text")
var clientId: String? = null,
@Column(name = "client_secret", columnDefinition = "text")
var clientSecret: String? = null,
@Column(name = "access_token", columnDefinition = "text")
var accessToken: String? = null,
@Column(name = "token_expires_at", columnDefinition = "timestamptz")
var tokenExpiresAt: OffsetDateTime? = null,
@Column(name = "is_test", nullable = false)
var isTest: Boolean = false,
@Column(name = "updated_at", columnDefinition = "timestamptz")
var updatedAt: OffsetDateTime = OffsetDateTime.now()
)

View File

@@ -1,51 +0,0 @@
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_settings",
uniqueConstraints = [UniqueConstraint(columnNames = ["property_id"])]
)
class PayuSettings(
@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_key", nullable = false, columnDefinition = "text")
var merchantKey: String,
@Column(name = "salt_32", columnDefinition = "text")
var salt32: String? = null,
@Column(name = "salt_256", columnDefinition = "text")
var salt256: String? = null,
@Column(name = "base_url", nullable = false, columnDefinition = "text")
var baseUrl: String = "https://secure.payu.in/_payment",
@Column(name = "is_test", nullable = false)
var isTest: Boolean = false,
@Column(name = "use_salt_256", nullable = false)
var useSalt256: Boolean = true,
@Column(name = "updated_at", nullable = false, columnDefinition = "timestamptz")
var updatedAt: OffsetDateTime = OffsetDateTime.now()
)

View File

@@ -0,0 +1,48 @@
package com.android.trisolarisserver.models.payment
import com.android.trisolarisserver.models.booking.Booking
import com.android.trisolarisserver.models.property.Property
import jakarta.persistence.*
import java.time.OffsetDateTime
import java.util.UUID
@Entity
@Table(name = "razorpay_payment_attempt")
class RazorpayPaymentAttempt(
@Id
@GeneratedValue
@Column(columnDefinition = "uuid")
val id: UUID? = null,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "property_id", nullable = false)
var property: Property,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "booking_id")
var booking: Booking? = null,
@Column(name = "event")
var event: String? = null,
@Column(name = "status")
var status: String? = null,
@Column(name = "amount")
var amount: Long? = null,
@Column(name = "currency")
var currency: String? = null,
@Column(name = "payment_id")
var paymentId: String? = null,
@Column(name = "order_id")
var orderId: String? = null,
@Column(name = "payload")
var payload: String? = null,
@Column(name = "received_at", nullable = false, columnDefinition = "timestamptz")
var receivedAt: OffsetDateTime = OffsetDateTime.now()
)

View File

@@ -0,0 +1,48 @@
package com.android.trisolarisserver.models.payment
import com.android.trisolarisserver.models.booking.Booking
import com.android.trisolarisserver.models.property.Property
import jakarta.persistence.*
import java.time.OffsetDateTime
import java.util.UUID
@Entity
@Table(name = "razorpay_payment_link_request")
class RazorpayPaymentLinkRequest(
@Id
@GeneratedValue
@Column(columnDefinition = "uuid")
val id: UUID? = null,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "property_id", nullable = false)
var property: Property,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "booking_id", nullable = false)
var booking: Booking,
@Column(name = "payment_link_id")
var paymentLinkId: String? = null,
@Column(name = "amount")
var amount: Long,
@Column(name = "currency")
var currency: String,
@Column(name = "status")
var status: String,
@Column(name = "short_url")
var shortUrl: String? = null,
@Column(name = "request_payload")
var requestPayload: String? = null,
@Column(name = "response_payload")
var responsePayload: String? = null,
@Column(name = "created_at", nullable = false, columnDefinition = "timestamptz")
val createdAt: OffsetDateTime = OffsetDateTime.now()
)

View File

@@ -2,22 +2,13 @@ package com.android.trisolarisserver.models.payment
import com.android.trisolarisserver.models.booking.Booking
import com.android.trisolarisserver.models.property.Property
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
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.*
import java.time.OffsetDateTime
import java.util.UUID
@Entity
@Table(name = "payu_qr_request")
class PayuQrRequest(
@Table(name = "razorpay_qr_request")
class RazorpayQrRequest(
@Id
@GeneratedValue
@Column(columnDefinition = "uuid")
@@ -31,23 +22,25 @@ class PayuQrRequest(
@JoinColumn(name = "booking_id", nullable = false)
var booking: Booking,
@Column(name = "txnid", nullable = false)
var txnid: String,
@Column(name = "qr_id")
var qrId: String? = null,
@Column(name = "amount", nullable = false)
@Column(name = "amount")
var amount: Long,
@Column(name = "currency", nullable = false)
@Column(name = "currency")
var currency: String,
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
var status: PayuQrStatus = PayuQrStatus.CREATED,
@Column(name = "status")
var status: String,
@Column(name = "request_payload", columnDefinition = "text")
@Column(name = "image_url")
var imageUrl: String? = null,
@Column(name = "request_payload")
var requestPayload: String? = null,
@Column(name = "response_payload", columnDefinition = "text")
@Column(name = "response_payload")
var responsePayload: String? = null,
@Column(name = "expiry_at", columnDefinition = "timestamptz")
@@ -56,10 +49,3 @@ class PayuQrRequest(
@Column(name = "created_at", nullable = false, columnDefinition = "timestamptz")
val createdAt: OffsetDateTime = OffsetDateTime.now()
)
enum class PayuQrStatus {
CREATED,
SENT,
SUCCESS,
FAILED
}

View File

@@ -0,0 +1,34 @@
package com.android.trisolarisserver.models.payment
import com.android.trisolarisserver.models.property.Property
import jakarta.persistence.*
import java.time.OffsetDateTime
import java.util.UUID
@Entity
@Table(name = "razorpay_settings")
class RazorpaySettings(
@Id
@GeneratedValue
@Column(columnDefinition = "uuid")
val id: UUID? = null,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "property_id", nullable = false, unique = true)
var property: Property,
@Column(name = "key_id", nullable = false)
var keyId: String,
@Column(name = "key_secret", nullable = false)
var keySecret: String,
@Column(name = "webhook_secret")
var webhookSecret: String? = null,
@Column(name = "is_test", nullable = false)
var isTest: Boolean = false,
@Column(name = "updated_at", nullable = false, columnDefinition = "timestamptz")
var updatedAt: OffsetDateTime = OffsetDateTime.now()
)

View File

@@ -1,20 +1,13 @@
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.*
import java.time.OffsetDateTime
import java.util.UUID
@Entity
@Table(name = "payu_webhook_log")
class PayuWebhookLog(
@Table(name = "razorpay_webhook_log")
class RazorpayWebhookLog(
@Id
@GeneratedValue
@Column(columnDefinition = "uuid")
@@ -24,15 +17,15 @@ class PayuWebhookLog(
@JoinColumn(name = "property_id", nullable = false)
var property: Property,
@Column(name = "headers", columnDefinition = "text")
@Column(name = "headers")
var headers: String? = null,
@Column(name = "payload", columnDefinition = "text")
@Column(name = "payload")
var payload: String? = null,
@Column(name = "content_type")
var contentType: String? = null,
@Column(name = "received_at", nullable = false, columnDefinition = "timestamptz")
val receivedAt: OffsetDateTime = OffsetDateTime.now()
var receivedAt: OffsetDateTime = OffsetDateTime.now()
)

View File

@@ -1,7 +0,0 @@
package com.android.trisolarisserver.repo
import com.android.trisolarisserver.models.payment.PayuPaymentAttempt
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface PayuPaymentAttemptRepo : JpaRepository<PayuPaymentAttempt, UUID>

View File

@@ -1,9 +0,0 @@
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<PayuPaymentLinkSettings, UUID> {
fun findByPropertyId(propertyId: UUID): PayuPaymentLinkSettings?
}

View File

@@ -1,15 +0,0 @@
package com.android.trisolarisserver.repo
import com.android.trisolarisserver.models.payment.PayuQrRequest
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface PayuQrRequestRepo : JpaRepository<PayuQrRequest, UUID> {
fun findByBookingId(bookingId: UUID): List<PayuQrRequest>
fun findTopByBookingIdAndAmountAndCurrencyAndStatusOrderByCreatedAtDesc(
bookingId: UUID,
amount: Long,
currency: String,
status: com.android.trisolarisserver.models.payment.PayuQrStatus
): PayuQrRequest?
}

View File

@@ -1,9 +0,0 @@
package com.android.trisolarisserver.repo
import com.android.trisolarisserver.models.payment.PayuSettings
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface PayuSettingsRepo : JpaRepository<PayuSettings, UUID> {
fun findByPropertyId(propertyId: UUID): PayuSettings?
}

View File

@@ -1,7 +0,0 @@
package com.android.trisolarisserver.repo
import com.android.trisolarisserver.models.payment.PayuWebhookLog
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface PayuWebhookLogRepo : JpaRepository<PayuWebhookLog, UUID>

View File

@@ -0,0 +1,7 @@
package com.android.trisolarisserver.repo
import com.android.trisolarisserver.models.payment.RazorpayPaymentAttempt
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface RazorpayPaymentAttemptRepo : JpaRepository<RazorpayPaymentAttempt, UUID>

View File

@@ -0,0 +1,14 @@
package com.android.trisolarisserver.repo
import com.android.trisolarisserver.models.payment.RazorpayPaymentLinkRequest
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface RazorpayPaymentLinkRequestRepo : JpaRepository<RazorpayPaymentLinkRequest, UUID> {
fun findTopByBookingIdAndAmountAndCurrencyAndStatusOrderByCreatedAtDesc(
bookingId: UUID,
amount: Long,
currency: String,
status: String
): RazorpayPaymentLinkRequest?
}

View File

@@ -0,0 +1,14 @@
package com.android.trisolarisserver.repo
import com.android.trisolarisserver.models.payment.RazorpayQrRequest
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface RazorpayQrRequestRepo : JpaRepository<RazorpayQrRequest, UUID> {
fun findTopByBookingIdAndAmountAndCurrencyAndStatusOrderByCreatedAtDesc(
bookingId: UUID,
amount: Long,
currency: String,
status: String
): RazorpayQrRequest?
}

View File

@@ -0,0 +1,9 @@
package com.android.trisolarisserver.repo
import com.android.trisolarisserver.models.payment.RazorpaySettings
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface RazorpaySettingsRepo : JpaRepository<RazorpaySettings, UUID> {
fun findByPropertyId(propertyId: UUID): RazorpaySettings?
}

View File

@@ -0,0 +1,7 @@
package com.android.trisolarisserver.repo
import com.android.trisolarisserver.models.payment.RazorpayWebhookLog
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface RazorpayWebhookLogRepo : JpaRepository<RazorpayWebhookLog, UUID>

View File

@@ -10,8 +10,8 @@ internal object PublicEndpoints {
private val roomTypes = Regex("^/properties/[^/]+/room-types$")
private val roomTypeImages = Regex("^/properties/[^/]+/room-types/[^/]+/images$")
private val iconPngFile = Regex("^/icons/png/[^/]+$")
private val payuWebhook = Regex("^/properties/[^/]+/payu/webhook$")
private val payuReturn = Regex("^/properties/[^/]+/payu/return/(success|failure)$")
private val razorpayWebhook = Regex("^/properties/[^/]+/razorpay/webhook$")
private val razorpayReturn = Regex("^/properties/[^/]+/razorpay/return/(success|failure)$")
private val guestDocumentFile = Regex("^/properties/[^/]+/guests/[^/]+/documents/[^/]+/file$")
fun isPublic(request: HttpServletRequest): Boolean {
@@ -26,8 +26,8 @@ internal object PublicEndpoints {
|| (roomsByType.matches(path) && method == "GET")
|| (roomTypes.matches(path) && method == "GET")
|| roomTypeImages.matches(path)
|| (payuWebhook.matches(path) && method == "POST")
|| payuReturn.matches(path)
|| (razorpayWebhook.matches(path) && method == "POST")
|| razorpayReturn.matches(path)
|| (path == "/image-tags" && method == "GET")
|| path == "/icons/png"
|| iconPngFile.matches(path)