Replace PayU integration with Razorpay
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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>
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user