add payment ledger impl and payu impl

This commit is contained in:
androidlover5842
2026-01-30 10:17:17 +05:30
parent 2d75b88892
commit c5e0648dd1
25 changed files with 1677 additions and 6 deletions

View File

@@ -35,6 +35,7 @@ android {
} }
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
} }
} }
@@ -60,6 +61,7 @@ dependencies {
implementation(libs.lottie.compose) implementation(libs.lottie.compose)
implementation(libs.calendar.compose) implementation(libs.calendar.compose)
implementation(libs.libphonenumber) implementation(libs.libphonenumber)
implementation(libs.zxing.core)
implementation(platform(libs.firebase.bom)) implementation(platform(libs.firebase.bom))
implementation(libs.firebase.auth.ktx) implementation(libs.firebase.auth.ktx)
implementation(libs.kotlinx.coroutines.play.services) implementation(libs.kotlinx.coroutines.play.services)

View File

@@ -25,6 +25,7 @@ import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelection
import com.android.trisolarispms.ui.roomstay.BookingRoomStaysScreen import com.android.trisolarispms.ui.roomstay.BookingRoomStaysScreen
import com.android.trisolarispms.ui.roomstay.BookingDetailsTabsScreen import com.android.trisolarispms.ui.roomstay.BookingDetailsTabsScreen
import com.android.trisolarispms.ui.home.HomeScreen import com.android.trisolarispms.ui.home.HomeScreen
import com.android.trisolarispms.ui.payment.BookingPaymentsScreen
import com.android.trisolarispms.ui.property.AddPropertyScreen import com.android.trisolarispms.ui.property.AddPropertyScreen
import com.android.trisolarispms.ui.room.RoomFormScreen import com.android.trisolarispms.ui.room.RoomFormScreen
import com.android.trisolarispms.ui.room.RoomsScreen import com.android.trisolarispms.ui.room.RoomsScreen
@@ -35,6 +36,8 @@ import com.android.trisolarispms.ui.roomimage.ImageTagsScreen
import com.android.trisolarispms.ui.roomimage.AddImageTagScreen import com.android.trisolarispms.ui.roomimage.AddImageTagScreen
import com.android.trisolarispms.ui.roomimage.EditImageTagScreen import com.android.trisolarispms.ui.roomimage.EditImageTagScreen
import com.android.trisolarispms.ui.roomstay.ActiveRoomStaysScreen import com.android.trisolarispms.ui.roomstay.ActiveRoomStaysScreen
import com.android.trisolarispms.ui.payu.PayuSettingsScreen
import com.android.trisolarispms.ui.payu.PayuQrScreen
import com.android.trisolarispms.ui.roomtype.AddAmenityScreen import com.android.trisolarispms.ui.roomtype.AddAmenityScreen
import com.android.trisolarispms.ui.roomtype.AddRoomTypeScreen import com.android.trisolarispms.ui.roomtype.AddRoomTypeScreen
import com.android.trisolarispms.ui.roomtype.AmenitiesScreen import com.android.trisolarispms.ui.roomtype.AmenitiesScreen
@@ -83,6 +86,11 @@ class MainActivity : ComponentActivity() {
it == "ADMIN" || it == "MANAGER" || it == "STAFF" it == "ADMIN" || it == "MANAGER" || it == "STAFF"
} == true } == true
} }
val canManagePayuSettings: (String) -> Boolean = { propertyId ->
state.isSuperAdmin || state.propertyRoles[propertyId]?.any {
it == "ADMIN" || it == "MANAGER"
} == true
}
BackHandler(enabled = currentRoute != AppRoute.Home) { BackHandler(enabled = currentRoute != AppRoute.Home) {
when (currentRoute) { when (currentRoute) {
@@ -114,6 +122,15 @@ class MainActivity : ComponentActivity() {
currentRoute.propertyId, currentRoute.propertyId,
currentRoute.roomTypeId currentRoute.roomTypeId
) )
is AppRoute.PayuSettings -> route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
is AppRoute.PayuQr -> route.value = AppRoute.BookingDetailsTabs(
currentRoute.propertyId,
currentRoute.bookingId,
null
)
is AppRoute.CreateBooking -> route.value = AppRoute.ActiveRoomStays( is AppRoute.CreateBooking -> route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId, currentRoute.propertyId,
selectedPropertyName.value ?: "Property" selectedPropertyName.value ?: "Property"
@@ -157,6 +174,11 @@ class MainActivity : ComponentActivity() {
currentRoute.propertyId, currentRoute.propertyId,
selectedPropertyName.value ?: "Property" selectedPropertyName.value ?: "Property"
) )
is AppRoute.BookingPayments -> route.value = AppRoute.BookingDetailsTabs(
currentRoute.propertyId,
currentRoute.bookingId,
null
)
} }
} }
@@ -253,6 +275,8 @@ class MainActivity : ComponentActivity() {
onBack = { route.value = AppRoute.Home }, onBack = { route.value = AppRoute.Home },
onViewRooms = { route.value = AppRoute.Rooms(currentRoute.propertyId) }, onViewRooms = { route.value = AppRoute.Rooms(currentRoute.propertyId) },
onCreateBooking = { route.value = AppRoute.CreateBooking(currentRoute.propertyId) }, onCreateBooking = { route.value = AppRoute.CreateBooking(currentRoute.propertyId) },
showPayuSettings = canManagePayuSettings(currentRoute.propertyId),
onPayuSettings = { route.value = AppRoute.PayuSettings(currentRoute.propertyId) },
onManageRoomStay = { booking -> onManageRoomStay = { booking ->
val fromAt = booking.checkInAt?.takeIf { it.isNotBlank() } val fromAt = booking.checkInAt?.takeIf { it.isNotBlank() }
?: booking.expectedCheckInAt.orEmpty() ?: booking.expectedCheckInAt.orEmpty()
@@ -281,6 +305,28 @@ class MainActivity : ComponentActivity() {
) )
} }
) )
is AppRoute.PayuSettings -> PayuSettingsScreen(
propertyId = currentRoute.propertyId,
onBack = {
route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
}
)
is AppRoute.PayuQr -> PayuQrScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
pendingAmount = currentRoute.pendingAmount,
guestPhone = currentRoute.guestPhone,
onBack = {
route.value = AppRoute.BookingDetailsTabs(
currentRoute.propertyId,
currentRoute.bookingId,
null
)
}
)
is AppRoute.ManageRoomStaySelect -> ManageRoomStaySelectScreen( is AppRoute.ManageRoomStaySelect -> ManageRoomStaySelectScreen(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
bookingFromAt = currentRoute.fromAt, bookingFromAt = currentRoute.fromAt,
@@ -420,6 +466,32 @@ class MainActivity : ComponentActivity() {
currentRoute.bookingId, currentRoute.bookingId,
guestId guestId
) )
},
onOpenPayuQr = { pendingAmount, guestPhone ->
route.value = AppRoute.PayuQr(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
pendingAmount = pendingAmount,
guestPhone = guestPhone
)
},
onOpenPayments = {
route.value = AppRoute.BookingPayments(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId
)
}
)
is AppRoute.BookingPayments -> BookingPaymentsScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
canAddCash = canManagePayuSettings(currentRoute.propertyId),
onBack = {
route.value = AppRoute.BookingDetailsTabs(
currentRoute.propertyId,
currentRoute.bookingId,
null
)
} }
) )
is AppRoute.Rooms -> RoomsScreen( is AppRoute.Rooms -> RoomsScreen(

View File

@@ -15,4 +15,6 @@ interface ApiService :
TransportApi, TransportApi,
InboundEmailApi, InboundEmailApi,
AmenityApi, AmenityApi,
RatePlanApi RatePlanApi,
PayuSettingsApi,
PayuPaymentLinkSettingsApi

View File

@@ -14,6 +14,12 @@ import com.android.trisolarispms.data.api.model.BookingBulkCheckInRequest
import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest
import com.android.trisolarispms.data.api.model.BookingDetailsResponse import com.android.trisolarispms.data.api.model.BookingDetailsResponse
import com.android.trisolarispms.data.api.model.RoomStayDto import com.android.trisolarispms.data.api.model.RoomStayDto
import com.android.trisolarispms.data.api.model.PayuQrRequest
import com.android.trisolarispms.data.api.model.PayuQrResponse
import com.android.trisolarispms.data.api.model.PayuLinkRequest
import com.android.trisolarispms.data.api.model.PayuLinkResponse
import com.android.trisolarispms.data.api.model.PaymentDto
import com.android.trisolarispms.data.api.model.PaymentCreateRequest
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
@@ -95,4 +101,31 @@ interface BookingApi {
@Path("bookingId") bookingId: String, @Path("bookingId") bookingId: String,
@Body body: BookingRoomStayCreateRequest @Body body: BookingRoomStayCreateRequest
): Response<RoomStayDto> ): Response<RoomStayDto>
@POST("properties/{propertyId}/bookings/{bookingId}/payments/payu/qr")
suspend fun generatePayuQr(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: PayuQrRequest
): Response<PayuQrResponse>
@POST("properties/{propertyId}/bookings/{bookingId}/payments/payu/link")
suspend fun generatePayuLink(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: PayuLinkRequest
): Response<PayuLinkResponse>
@GET("properties/{propertyId}/bookings/{bookingId}/payments")
suspend fun listPayments(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String
): Response<List<PaymentDto>>
@POST("properties/{propertyId}/bookings/{bookingId}/payments")
suspend fun createPayment(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: PaymentCreateRequest
): Response<PaymentDto>
} }

View File

@@ -0,0 +1,22 @@
package com.android.trisolarispms.data.api
import com.android.trisolarispms.data.api.model.PayuPaymentLinkSettingsRequest
import com.android.trisolarispms.data.api.model.PayuPaymentLinkSettingsResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PUT
import retrofit2.http.Path
interface PayuPaymentLinkSettingsApi {
@GET("properties/{propertyId}/payu-payment-link-settings")
suspend fun getPayuPaymentLinkSettings(
@Path("propertyId") propertyId: String
): Response<PayuPaymentLinkSettingsResponse>
@PUT("properties/{propertyId}/payu-payment-link-settings")
suspend fun updatePayuPaymentLinkSettings(
@Path("propertyId") propertyId: String,
@Body body: PayuPaymentLinkSettingsRequest
): Response<PayuPaymentLinkSettingsResponse>
}

View File

@@ -0,0 +1,22 @@
package com.android.trisolarispms.data.api
import com.android.trisolarispms.data.api.model.PayuSettingsRequest
import com.android.trisolarispms.data.api.model.PayuSettingsResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PUT
import retrofit2.http.Path
interface PayuSettingsApi {
@GET("properties/{propertyId}/payu-settings")
suspend fun getPayuSettings(
@Path("propertyId") propertyId: String
): Response<PayuSettingsResponse>
@PUT("properties/{propertyId}/payu-settings")
suspend fun updatePayuSettings(
@Path("propertyId") propertyId: String,
@Body body: PayuSettingsRequest
): Response<PayuSettingsResponse>
}

View File

@@ -0,0 +1,25 @@
package com.android.trisolarispms.data.api.model
data class PaymentDto(
val id: String? = null,
val bookingId: String? = null,
val amount: Long? = null,
val currency: String? = null,
val method: String? = null,
val gatewayPaymentId: String? = null,
val gatewayTxnId: String? = null,
val bankRefNum: String? = null,
val mode: String? = null,
val pgType: String? = null,
val payerVpa: String? = null,
val payerName: String? = null,
val paymentSource: String? = null,
val reference: String? = null,
val notes: String? = null,
val receivedAt: String? = null,
val receivedByUserId: String? = null
)
data class PaymentCreateRequest(
val amount: Long
)

View File

@@ -0,0 +1,18 @@
package com.android.trisolarispms.data.api.model
data class PayuPaymentLinkSettingsRequest(
val merchantId: String,
val clientId: String,
val clientSecret: String,
val isTest: Boolean
)
data class PayuPaymentLinkSettingsResponse(
val propertyId: String? = null,
val configured: Boolean? = null,
val merchantId: String? = null,
val isTest: Boolean? = null,
val hasClientId: Boolean? = null,
val hasClientSecret: Boolean? = null,
val hasAccessToken: Boolean? = null
)

View File

@@ -0,0 +1,26 @@
package com.android.trisolarispms.data.api.model
import com.google.gson.JsonElement
data class PayuQrRequest(
val amount: Long,
val deviceInfo: String
)
data class PayuQrResponse(
val txnid: String? = null,
val amount: Long? = null,
val currency: String? = null,
val payuResponse: JsonElement? = null
)
data class PayuLinkRequest(
val amount: Long
)
data class PayuLinkResponse(
val amount: Long? = null,
val currency: String? = null,
val paymentLink: String? = null,
val payuResponse: JsonElement? = null
)

View File

@@ -0,0 +1,21 @@
package com.android.trisolarispms.data.api.model
import com.google.gson.annotations.SerializedName
data class PayuSettingsRequest(
val merchantKey: String,
val salt32: String? = null,
val salt256: String? = null,
@SerializedName("isTest") val isTest: Boolean,
val useSalt256: Boolean
)
data class PayuSettingsResponse(
val propertyId: String? = null,
val configured: Boolean? = null,
val merchantKey: String? = null,
@SerializedName("test") val isTest: Boolean? = null,
val useSalt256: Boolean? = null,
val hasSalt32: Boolean? = null,
val hasSalt256: Boolean? = null
)

View File

@@ -47,6 +47,10 @@ sealed interface AppRoute {
val bookingId: String, val bookingId: String,
val guestId: String? val guestId: String?
) : AppRoute ) : AppRoute
data class BookingPayments(
val propertyId: String,
val bookingId: String
) : AppRoute
data object AddProperty : AppRoute data object AddProperty : AppRoute
data class ActiveRoomStays(val propertyId: String, val propertyName: String) : AppRoute data class ActiveRoomStays(val propertyId: String, val propertyName: String) : AppRoute
data class Rooms(val propertyId: String) : AppRoute data class Rooms(val propertyId: String) : AppRoute
@@ -63,6 +67,13 @@ sealed interface AppRoute {
val ratePlanId: String, val ratePlanId: String,
val ratePlanCode: String val ratePlanCode: String
) : AppRoute ) : AppRoute
data class PayuSettings(val propertyId: String) : AppRoute
data class PayuQr(
val propertyId: String,
val bookingId: String,
val pendingAmount: Long?,
val guestPhone: String?
) : AppRoute
data object Amenities : AppRoute data object Amenities : AppRoute
data object AddAmenity : AppRoute data object AddAmenity : AppRoute
data class EditAmenity(val amenityId: String) : AppRoute data class EditAmenity(val amenityId: String) : AppRoute

View File

@@ -0,0 +1,164 @@
package com.android.trisolarispms.ui.payment
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.data.api.model.PaymentDto
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun BookingPaymentsScreen(
propertyId: String,
bookingId: String,
canAddCash: Boolean,
onBack: () -> Unit,
viewModel: BookingPaymentsViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val amountInput = remember { mutableStateOf("") }
LaunchedEffect(propertyId, bookingId) {
viewModel.load(propertyId, bookingId)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Payments") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
) {
if (canAddCash) {
OutlinedTextField(
value = amountInput.value,
onValueChange = { amountInput.value = it.filter { ch -> ch.isDigit() } },
label = { Text("Cash amount") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
val amount = amountInput.value.toLongOrNull() ?: 0L
viewModel.addCashPayment(propertyId, bookingId, amount)
},
enabled = !state.isLoading,
modifier = Modifier.fillMaxWidth()
) {
Text("Add cash payment")
}
Spacer(modifier = Modifier.height(12.dp))
}
if (state.isLoading) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(8.dp))
}
state.error?.let {
Text(text = it, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp))
}
if (!state.isLoading && state.error == null && state.payments.isEmpty()) {
Text(text = "No payments yet")
}
LazyColumn(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize()
) {
items(state.payments) { payment ->
PaymentCard(payment = payment)
}
}
}
}
}
@Composable
private fun PaymentCard(payment: PaymentDto) {
val date = payment.receivedAt?.let {
runCatching { OffsetDateTime.parse(it) }.getOrNull()
}
val dateText = date?.format(DateTimeFormatter.ofPattern("dd MMM yyyy, hh:mm a"))
val isCash = payment.method == "CASH"
Card(
colors = CardDefaults.cardColors(
containerColor = if (isCash) {
MaterialTheme.colorScheme.tertiaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
}
),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(12.dp)) {
val amountText = buildString {
append(payment.amount?.toString() ?: "-")
payment.currency?.let { append(" $it") }
}
Text(text = amountText, style = MaterialTheme.typography.titleMedium)
payment.method?.let {
Text(text = "Method: $it", style = MaterialTheme.typography.bodySmall)
}
payment.payerVpa?.takeIf { it.isNotBlank() }?.let {
Text(text = "UPI: $it", style = MaterialTheme.typography.bodySmall)
}
payment.payerName?.takeIf { it.isNotBlank() }?.let {
Text(text = "Payer: $it", style = MaterialTheme.typography.bodySmall)
}
dateText?.let {
Text(text = "Received: $it", style = MaterialTheme.typography.bodySmall)
}
payment.reference?.takeIf { it.isNotBlank() }?.let {
Text(text = "Ref: $it", style = MaterialTheme.typography.bodySmall)
}
payment.notes?.takeIf { it.isNotBlank() }?.let {
Text(text = it, style = MaterialTheme.typography.bodySmall)
}
}
}
}

View File

@@ -0,0 +1,9 @@
package com.android.trisolarispms.ui.payment
import com.android.trisolarispms.data.api.model.PaymentDto
data class BookingPaymentsState(
val isLoading: Boolean = false,
val error: String? = null,
val payments: List<PaymentDto> = emptyList()
)

View File

@@ -0,0 +1,90 @@
package com.android.trisolarispms.ui.payment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.ApiClient
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class BookingPaymentsViewModel : ViewModel() {
private val _state = MutableStateFlow(BookingPaymentsState())
val state: StateFlow<BookingPaymentsState> = _state
fun load(propertyId: String, bookingId: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.listPayments(propertyId, bookingId)
val body = response.body()
if (response.isSuccessful && body != null) {
_state.update {
it.copy(
isLoading = false,
payments = body,
error = null
)
}
} else {
_state.update {
it.copy(
isLoading = false,
error = "Load failed: ${response.code()}"
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isLoading = false,
error = e.localizedMessage ?: "Load failed"
)
}
}
}
}
fun addCashPayment(propertyId: String, bookingId: String, amount: Long) {
if (amount <= 0) {
_state.update { it.copy(error = "Amount must be greater than 0") }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.createPayment(
propertyId = propertyId,
bookingId = bookingId,
body = com.android.trisolarispms.data.api.model.PaymentCreateRequest(amount = amount)
)
val body = response.body()
if (response.isSuccessful && body != null) {
_state.update { current ->
current.copy(
isLoading = false,
payments = listOf(body) + current.payments,
error = null
)
}
} else {
_state.update {
it.copy(
isLoading = false,
error = "Create failed: ${response.code()}"
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isLoading = false,
error = e.localizedMessage ?: "Create failed"
)
}
}
}
}
}

View File

@@ -0,0 +1,16 @@
package com.android.trisolarispms.ui.payu
data class PayuPaymentLinkSettingsState(
val merchantId: String = "",
val clientId: String = "",
val clientSecret: String = "",
val isTest: Boolean = false,
val configured: Boolean = false,
val hasClientId: Boolean = false,
val hasClientSecret: Boolean = false,
val hasAccessToken: Boolean = false,
val isLoading: Boolean = false,
val isSaving: Boolean = false,
val error: String? = null,
val message: String? = null
)

View File

@@ -0,0 +1,120 @@
package com.android.trisolarispms.ui.payu
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.ApiClient
import com.android.trisolarispms.data.api.model.PayuPaymentLinkSettingsRequest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class PayuPaymentLinkSettingsViewModel : ViewModel() {
private val _state = MutableStateFlow(PayuPaymentLinkSettingsState())
val state: StateFlow<PayuPaymentLinkSettingsState> = _state
fun load(propertyId: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null, message = null) }
try {
val api = ApiClient.create()
val response = api.getPayuPaymentLinkSettings(propertyId)
val body = response.body()
if (response.isSuccessful && body != null) {
_state.update {
it.copy(
merchantId = body.merchantId.orEmpty(),
isTest = body.isTest == true,
configured = body.configured == true,
hasClientId = body.hasClientId == true,
hasClientSecret = body.hasClientSecret == true,
hasAccessToken = body.hasAccessToken == true,
clientId = "",
clientSecret = "",
isLoading = false,
error = null
)
}
} else {
_state.update {
it.copy(
isLoading = false,
error = "Load failed: ${response.code()}",
message = null
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isLoading = false,
error = e.localizedMessage ?: "Load failed",
message = null
)
}
}
}
}
fun onMerchantIdChange(value: String) {
_state.update { it.copy(merchantId = value, error = null) }
}
fun onClientIdChange(value: String) {
_state.update { it.copy(clientId = value, error = null) }
}
fun onClientSecretChange(value: String) {
_state.update { it.copy(clientSecret = value, error = null) }
}
fun onIsTestChange(value: Boolean) {
_state.update { it.copy(isTest = value, error = null) }
}
fun save(propertyId: String) {
val current = state.value
val merchantId = current.merchantId.trim()
val clientId = current.clientId.trim()
val clientSecret = current.clientSecret.trim()
if (merchantId.isBlank() || clientId.isBlank() || clientSecret.isBlank()) {
_state.update { it.copy(error = "All fields are required") }
return
}
viewModelScope.launch {
_state.update { it.copy(isSaving = true, error = null, message = null) }
try {
val api = ApiClient.create()
val response = api.updatePayuPaymentLinkSettings(
propertyId = propertyId,
body = PayuPaymentLinkSettingsRequest(
merchantId = merchantId,
clientId = clientId,
clientSecret = clientSecret,
isTest = current.isTest
)
)
if (response.isSuccessful) {
_state.update { it.copy(isSaving = false, message = "Saved", error = null) }
load(propertyId)
} else {
_state.update {
it.copy(
isSaving = false,
error = "Save failed: ${response.code()}",
message = null
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isSaving = false,
error = e.localizedMessage ?: "Save failed",
message = null
)
}
}
}
}
}

View File

@@ -0,0 +1,189 @@
package com.android.trisolarispms.ui.payu
import android.graphics.Bitmap
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
import com.google.zxing.common.BitMatrix
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun PayuQrScreen(
propertyId: String,
bookingId: String,
pendingAmount: Long?,
guestPhone: String?,
onBack: () -> Unit,
viewModel: PayuQrViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val clipboard = LocalClipboardManager.current
val context = LocalContext.current
LaunchedEffect(pendingAmount) {
viewModel.setInitialAmount(pendingAmount)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("PayU QR") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Top
) {
OutlinedTextField(
value = state.amountInput,
onValueChange = viewModel::onAmountChange,
label = { Text("Amount") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = {
if (!guestPhone.isNullOrBlank()) {
viewModel.generateLink(propertyId, bookingId) { link ->
val uri = Uri.parse("smsto:${guestPhone}")
val intent = Intent(Intent.ACTION_SENDTO, uri)
.putExtra("sms_body", "Pay using this link: $link")
context.startActivity(intent)
}
}
},
enabled = !state.isLoading && !guestPhone.isNullOrBlank(),
modifier = Modifier.weight(1f)
) {
Text("Share Link")
}
Button(
onClick = { viewModel.generate(propertyId, bookingId) },
enabled = !state.isLoading,
modifier = Modifier.weight(1f)
) {
if (state.isLoading) {
CircularProgressIndicator(
modifier = Modifier
.height(16.dp)
.padding(end = 8.dp),
strokeWidth = 2.dp
)
Text("Generating...")
} else {
Text("Generate QR")
}
}
}
state.error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
if (!state.qrString.isNullOrBlank()) {
Spacer(modifier = Modifier.height(16.dp))
val qrBitmap = remember(state.qrString) { state.qrString?.let { createQrBitmap(it) } }
qrBitmap?.let {
androidx.compose.foundation.Image(
bitmap = it.asImageBitmap(),
contentDescription = "PayU QR",
modifier = Modifier
.fillMaxWidth()
.height(240.dp)
)
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Amount: ${state.amount ?: "-"} ${state.currency ?: ""}",
style = MaterialTheme.typography.bodyMedium
)
Button(
onClick = {
clipboard.setText(AnnotatedString(state.qrString.orEmpty()))
}
) {
Text("Copy QR string")
}
}
}
}
}
}
private fun createQrBitmap(content: String, size: Int = 600): Bitmap {
val matrix: BitMatrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size)
val width = matrix.width
val height = matrix.height
val pixels = IntArray(width * height)
for (y in 0 until height) {
val offset = y * width
for (x in 0 until width) {
pixels[offset + x] = if (matrix.get(x, y)) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
}
}
return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).apply {
setPixels(pixels, 0, width, 0, 0, width, height)
}
}

View File

@@ -0,0 +1,14 @@
package com.android.trisolarispms.ui.payu
data class PayuQrState(
val deviceInfo: String = "",
val amountInput: String = "",
val isLoading: Boolean = false,
val error: String? = null,
val txnId: String? = null,
val amount: Long? = null,
val currency: String? = null,
val qrString: String? = null,
val payuResponseRaw: String? = null,
val paymentLink: String? = null
)

View File

@@ -0,0 +1,159 @@
package com.android.trisolarispms.ui.payu
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.ApiClient
import com.android.trisolarispms.data.api.model.PayuLinkRequest
import com.android.trisolarispms.data.api.model.PayuQrRequest
import com.google.gson.Gson
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class PayuQrViewModel : ViewModel() {
private val gson = Gson()
private val _state = MutableStateFlow(
PayuQrState(deviceInfo = buildDeviceInfo())
)
val state: StateFlow<PayuQrState> = _state
fun onAmountChange(value: String) {
val digits = value.filter { it.isDigit() }
_state.update { it.copy(amountInput = digits, error = null) }
}
fun setInitialAmount(amount: Long?) {
if (amount == null || amount <= 0) return
_state.update { current ->
if (current.amountInput.isBlank()) {
current.copy(amountInput = amount.toString())
} else {
current
}
}
}
fun generate(propertyId: String, bookingId: String) {
val current = state.value
val amount = current.amountInput.toLongOrNull()?.takeIf { it > 0 }
if (amount == null) {
_state.update { it.copy(error = "Amount is required") }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.generatePayuQr(
propertyId = propertyId,
bookingId = bookingId,
body = PayuQrRequest(
amount = amount,
deviceInfo = current.deviceInfo
)
)
val body = response.body()
if (response.isSuccessful && body != null) {
val (qrString, rawPayload) = extractQrString(body.payuResponse)
_state.update {
it.copy(
isLoading = false,
txnId = body.txnid,
amount = body.amount,
currency = body.currency,
qrString = qrString,
payuResponseRaw = rawPayload,
error = null
)
}
} else {
_state.update {
it.copy(
isLoading = false,
error = "QR request failed: ${response.code()}"
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isLoading = false,
error = e.localizedMessage ?: "QR request failed"
)
}
}
}
}
fun generateLink(propertyId: String, bookingId: String, onReady: (String) -> Unit) {
val current = state.value
val amount = current.amountInput.toLongOrNull()?.takeIf { it > 0 }
if (amount == null) {
_state.update { it.copy(error = "Amount is required") }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.generatePayuLink(
propertyId = propertyId,
bookingId = bookingId,
body = PayuLinkRequest(amount = amount)
)
val body = response.body()
if (response.isSuccessful && body?.paymentLink != null) {
_state.update {
it.copy(
isLoading = false,
paymentLink = body.paymentLink,
amount = body.amount,
currency = body.currency,
error = null
)
}
onReady(body.paymentLink)
} else {
_state.update {
it.copy(
isLoading = false,
error = "Link request failed: ${response.code()}"
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isLoading = false,
error = e.localizedMessage ?: "Link request failed"
)
}
}
}
}
private fun extractQrString(payuResponse: JsonElement?): Pair<String?, String?> {
if (payuResponse == null) return null to null
val raw = gson.toJson(payuResponse)
val root = when {
payuResponse.isJsonObject -> payuResponse.asJsonObject
payuResponse.isJsonPrimitive && payuResponse.asJsonPrimitive.isString -> {
runCatching {
gson.fromJson(payuResponse.asString, JsonObject::class.java)
}.getOrNull()
}
else -> null
}
val qrString = root?.getAsJsonObject("result")?.get("qrString")?.asString
return qrString to raw
}
private fun buildDeviceInfo(): String {
val release = android.os.Build.VERSION.RELEASE
val model = android.os.Build.MODEL
return "Android $release; $model; PMS"
}
}

View File

@@ -0,0 +1,437 @@
package com.android.trisolarispms.ui.payu
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import kotlinx.coroutines.launch
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun PayuSettingsScreen(
propertyId: String,
onBack: () -> Unit,
viewModel: PayuSettingsViewModel = viewModel(),
linkViewModel: PayuPaymentLinkSettingsViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val linkState by linkViewModel.state.collectAsState()
val clipboard = LocalClipboardManager.current
val pagerState = rememberPagerState(initialPage = 0, pageCount = { 2 })
val scope = rememberCoroutineScope()
LaunchedEffect(propertyId) {
viewModel.load(propertyId)
linkViewModel.load(propertyId)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Payu Settings") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
TabRow(selectedTabIndex = pagerState.currentPage) {
Tab(
selected = pagerState.currentPage == 0,
onClick = { scope.launch { pagerState.animateScrollToPage(0) } },
text = { Text("QR") }
)
Tab(
selected = pagerState.currentPage == 1,
onClick = { scope.launch { pagerState.animateScrollToPage(1) } },
text = { Text("Payment Links") }
)
}
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize()
) { page ->
when (page) {
0 -> PayuQrSettingsTab(
propertyId = propertyId,
state = state,
onMerchantKeyChange = viewModel::onMerchantKeyChange,
onIsTestChange = viewModel::onIsTestChange,
onUseSalt256Change = viewModel::onUseSalt256Change,
onSalt32Change = viewModel::onSalt32Change,
onSalt256Change = viewModel::onSalt256Change,
onSave = { viewModel.save(propertyId) },
clipboard = clipboard
)
1 -> PayuPaymentLinksTab(
propertyId = propertyId,
state = linkState,
onMerchantIdChange = linkViewModel::onMerchantIdChange,
onClientIdChange = linkViewModel::onClientIdChange,
onClientSecretChange = linkViewModel::onClientSecretChange,
onIsTestChange = linkViewModel::onIsTestChange,
onSave = { linkViewModel.save(propertyId) }
)
}
}
}
}
}
@Composable
private fun PayuQrSettingsTab(
propertyId: String,
state: PayuSettingsState,
onMerchantKeyChange: (String) -> Unit,
onIsTestChange: (Boolean) -> Unit,
onUseSalt256Change: (Boolean) -> Unit,
onSalt32Change: (String) -> Unit,
onSalt256Change: (String) -> Unit,
onSave: () -> Unit,
clipboard: ClipboardManager
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Top
) {
Text(
text = "Payu Settings",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "More payment settings will be added later. For now, configure PayU.",
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Success/failure URLs are derived by the server using this property ID.",
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.height(12.dp))
if (state.isLoading) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(12.dp))
}
Text(
text = if (state.configured) "Status: Configured" else "Status: Not configured",
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.height(8.dp))
state.message?.let {
Text(text = it, color = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.height(8.dp))
}
state.error?.let {
Text(text = it, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp))
}
OutlinedTextField(
value = state.merchantKey,
onValueChange = onMerchantKeyChange,
label = { Text("Merchant key") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = "Use PayU test URL")
Switch(
checked = state.isTest,
onCheckedChange = onIsTestChange
)
}
Spacer(modifier = Modifier.height(6.dp))
Text(
text = if (state.isTest) {
"Base URL: https://test.payu.in/_payment"
} else {
"Base URL: https://secure.payu.in/_payment"
},
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = "Use salt256")
Switch(
checked = state.useSalt256,
onCheckedChange = onUseSalt256Change
)
}
Spacer(modifier = Modifier.height(8.dp))
if (state.useSalt256) {
OutlinedTextField(
value = state.salt256,
onValueChange = onSalt256Change,
label = { Text("Salt256") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
modifier = Modifier.fillMaxWidth()
)
if (state.hasSalt256) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Salt256 saved",
style = MaterialTheme.typography.bodySmall
)
}
} else {
OutlinedTextField(
value = state.salt32,
onValueChange = onSalt32Change,
label = { Text("Salt32") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
modifier = Modifier.fillMaxWidth()
)
if (state.hasSalt32) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Salt32 saved",
style = MaterialTheme.typography.bodySmall
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onSave,
enabled = !state.isSaving,
modifier = Modifier.fillMaxWidth()
) {
if (state.isSaving) {
CircularProgressIndicator(
modifier = Modifier
.height(16.dp)
.padding(end = 8.dp),
strokeWidth = 2.dp
)
Text("Saving...")
} else {
Text("Save")
}
}
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = "https://api.hoteltrisolaris.in/properties/$propertyId/payu/return/success",
onValueChange = {},
label = { Text("Success URL") },
readOnly = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = "https://api.hoteltrisolaris.in/properties/$propertyId/payu/return/failure",
onValueChange = {},
label = { Text("Failure URL") },
readOnly = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = {
clipboard.setText(
AnnotatedString(
"https://api.hoteltrisolaris.in/properties/$propertyId/payu/return/success"
)
)
}
) {
Text("Copy success URL")
}
Button(
onClick = {
clipboard.setText(
AnnotatedString(
"https://api.hoteltrisolaris.in/properties/$propertyId/payu/return/failure"
)
)
}
) {
Text("Copy failure URL")
}
}
Spacer(modifier = Modifier.height(12.dp))
}
}
@Composable
private fun PayuPaymentLinksTab(
propertyId: String,
state: PayuPaymentLinkSettingsState,
onMerchantIdChange: (String) -> Unit,
onClientIdChange: (String) -> Unit,
onClientSecretChange: (String) -> Unit,
onIsTestChange: (Boolean) -> Unit,
onSave: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Top
) {
if (state.isLoading) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(12.dp))
}
Text(
text = if (state.configured) "Status: Configured" else "Status: Not configured",
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.height(8.dp))
if (state.hasClientId) {
Text(text = "Client ID saved", style = MaterialTheme.typography.bodySmall)
}
if (state.hasClientSecret) {
Text(text = "Client Secret saved", style = MaterialTheme.typography.bodySmall)
}
if (state.hasAccessToken) {
Text(text = "Access token available", style = MaterialTheme.typography.bodySmall)
}
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Payment Link Settings",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(12.dp))
state.message?.let {
Text(text = it, color = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.height(8.dp))
}
state.error?.let {
Text(text = it, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp))
}
OutlinedTextField(
value = state.merchantId,
onValueChange = onMerchantIdChange,
label = { Text("Merchant ID") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.clientId,
onValueChange = onClientIdChange,
label = { Text("Client ID") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.clientSecret,
onValueChange = onClientSecretChange,
label = { Text("Client Secret") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = "Use PayU test environment")
Switch(
checked = state.isTest,
onCheckedChange = onIsTestChange
)
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onSave,
enabled = !state.isSaving,
modifier = Modifier.fillMaxWidth()
) {
if (state.isSaving) {
CircularProgressIndicator(
modifier = Modifier
.height(16.dp)
.padding(end = 8.dp),
strokeWidth = 2.dp
)
Text("Saving...")
} else {
Text("Save")
}
}
}
}

View File

@@ -0,0 +1,16 @@
package com.android.trisolarispms.ui.payu
data class PayuSettingsState(
val merchantKey: String = "",
val salt32: String = "",
val salt256: String = "",
val isTest: Boolean = false,
val useSalt256: Boolean = false,
val hasSalt32: Boolean = false,
val hasSalt256: Boolean = false,
val configured: Boolean = false,
val isLoading: Boolean = false,
val isSaving: Boolean = false,
val error: String? = null,
val message: String? = null
)

View File

@@ -0,0 +1,145 @@
package com.android.trisolarispms.ui.payu
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.ApiClient
import com.android.trisolarispms.data.api.model.PayuSettingsRequest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class PayuSettingsViewModel : ViewModel() {
private val _state = MutableStateFlow(PayuSettingsState())
val state: StateFlow<PayuSettingsState> = _state
fun load(propertyId: String) {
viewModelScope.launch {
loadInternal(propertyId = propertyId, showLoading = true, message = null)
}
}
fun onMerchantKeyChange(value: String) {
_state.update { it.copy(merchantKey = value, error = null) }
}
fun onSalt32Change(value: String) {
_state.update { it.copy(salt32 = value, error = null) }
}
fun onSalt256Change(value: String) {
_state.update { it.copy(salt256 = value, error = null) }
}
fun onIsTestChange(value: Boolean) {
_state.update { it.copy(isTest = value, error = null) }
}
fun onUseSalt256Change(value: Boolean) {
_state.update { it.copy(useSalt256 = value, error = null) }
}
fun save(propertyId: String) {
val current = state.value
val merchantKey = current.merchantKey.trim()
val salt32 = current.salt32.trim()
val salt256 = current.salt256.trim()
if (merchantKey.isBlank()) {
_state.update { it.copy(error = "Merchant key is required") }
return
}
if (current.useSalt256 && salt256.isBlank() && !current.hasSalt256) {
_state.update { it.copy(error = "Salt256 is required") }
return
}
if (!current.useSalt256 && salt32.isBlank() && !current.hasSalt32) {
_state.update { it.copy(error = "Salt32 is required") }
return
}
viewModelScope.launch {
_state.update { it.copy(isSaving = true, error = null, message = null) }
try {
val api = ApiClient.create()
val response = api.updatePayuSettings(
propertyId = propertyId,
body = PayuSettingsRequest(
merchantKey = merchantKey,
salt32 = salt32.ifBlank { null },
salt256 = salt256.ifBlank { null },
isTest = current.isTest,
useSalt256 = current.useSalt256
)
)
if (response.isSuccessful) {
loadInternal(propertyId = propertyId, showLoading = false, message = "Saved")
} else {
_state.update {
it.copy(
isSaving = false,
error = "Save failed: ${response.code()}",
message = null
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isSaving = false,
error = e.localizedMessage ?: "Save failed",
message = null
)
}
}
}
}
private suspend fun loadInternal(propertyId: String, showLoading: Boolean, message: String?) {
if (showLoading) {
_state.update { it.copy(isLoading = true, error = null, message = message) }
} else {
_state.update { it.copy(error = null, message = message) }
}
try {
val api = ApiClient.create()
val response = api.getPayuSettings(propertyId)
val body = response.body()
if (response.isSuccessful && body != null) {
_state.update {
it.copy(
merchantKey = body.merchantKey.orEmpty(),
isTest = body.isTest == true,
useSalt256 = body.useSalt256 == true,
hasSalt32 = body.hasSalt32 == true,
hasSalt256 = body.hasSalt256 == true,
configured = body.configured == true,
salt32 = "",
salt256 = "",
isLoading = false,
isSaving = false,
error = null
)
}
} else {
_state.update {
it.copy(
isLoading = false,
isSaving = false,
error = "Load failed: ${response.code()}",
message = null
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isLoading = false,
isSaving = false,
error = e.localizedMessage ?: "Load failed",
message = null
)
}
}
}
}

View File

@@ -15,6 +15,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.MeetingRoom import androidx.compose.material.icons.filled.MeetingRoom
import androidx.compose.material.icons.filled.Payment
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -51,6 +52,8 @@ fun ActiveRoomStaysScreen(
onBack: () -> Unit, onBack: () -> Unit,
onViewRooms: () -> Unit, onViewRooms: () -> Unit,
onCreateBooking: () -> Unit, onCreateBooking: () -> Unit,
showPayuSettings: Boolean,
onPayuSettings: () -> Unit,
onManageRoomStay: (BookingListItem) -> Unit, onManageRoomStay: (BookingListItem) -> Unit,
onViewBookingStays: (BookingListItem) -> Unit, onViewBookingStays: (BookingListItem) -> Unit,
onOpenBookingDetails: (BookingListItem) -> Unit, onOpenBookingDetails: (BookingListItem) -> Unit,
@@ -76,6 +79,11 @@ fun ActiveRoomStaysScreen(
IconButton(onClick = onViewRooms) { IconButton(onClick = onViewRooms) {
Icon(Icons.Default.MeetingRoom, contentDescription = "Available Rooms") Icon(Icons.Default.MeetingRoom, contentDescription = "Available Rooms")
} }
if (showPayuSettings) {
IconButton(onClick = onPayuSettings) {
Icon(Icons.Default.Payment, contentDescription = "Payu Settings")
}
}
}, },
colors = TopAppBarDefaults.topAppBarColors() colors = TopAppBarDefaults.topAppBarColors()
) )

View File

@@ -5,11 +5,14 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -17,6 +20,8 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.filled.ReceiptLong
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -67,6 +72,8 @@ fun BookingDetailsTabsScreen(
onBack: () -> Unit, onBack: () -> Unit,
onEditCheckout: (String?, String?) -> Unit, onEditCheckout: (String?, String?) -> Unit,
onEditSignature: (String) -> Unit, onEditSignature: (String) -> Unit,
onOpenPayuQr: (Long?, String?) -> Unit,
onOpenPayments: () -> Unit,
staysViewModel: BookingRoomStaysViewModel = viewModel(), staysViewModel: BookingRoomStaysViewModel = viewModel(),
detailsViewModel: BookingDetailsViewModel = viewModel() detailsViewModel: BookingDetailsViewModel = viewModel()
) { ) {
@@ -126,7 +133,9 @@ fun BookingDetailsTabsScreen(
isLoading = detailsState.isLoading, isLoading = detailsState.isLoading,
error = detailsState.error, error = detailsState.error,
onEditCheckout = onEditCheckout, onEditCheckout = onEditCheckout,
onEditSignature = onEditSignature onEditSignature = onEditSignature,
onOpenPayuQr = onOpenPayuQr,
onOpenPayments = onOpenPayments
) )
1 -> BookingRoomStaysTabContent(staysState, staysViewModel) 1 -> BookingRoomStaysTabContent(staysState, staysViewModel)
} }
@@ -143,7 +152,9 @@ private fun GuestInfoTabContent(
isLoading: Boolean, isLoading: Boolean,
error: String?, error: String?,
onEditCheckout: (String?, String?) -> Unit, onEditCheckout: (String?, String?) -> Unit,
onEditSignature: (String) -> Unit onEditSignature: (String) -> Unit,
onOpenPayuQr: (Long?, String?) -> Unit,
onOpenPayments: () -> Unit
) { ) {
val displayZone = remember { ZoneId.of("Asia/Kolkata") } val displayZone = remember { ZoneId.of("Asia/Kolkata") }
val dateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") } val dateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") }
@@ -219,7 +230,33 @@ private fun GuestInfoTabContent(
} }
} }
SectionCard(title = "Calculations") { val hasGuestName = details?.guestName?.isNotBlank() == true
val hasGuestPhone = details?.guestPhone?.isNotBlank() == true
val hasPending = (details?.pending ?: 0L) > 1L
SectionCard(
title = "Calculations",
headerContent = {
if (hasGuestName && hasGuestPhone && hasPending) {
IconButton(onClick = { onOpenPayuQr(details.pending, details.guestPhone) }) {
Icon(
imageVector = Icons.Default.QrCode,
contentDescription = "PayU QR",
tint = MaterialTheme.colorScheme.primary
)
}
}
IconButton(
onClick = onOpenPayments,
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = Icons.Default.ReceiptLong,
contentDescription = "Payments",
tint = MaterialTheme.colorScheme.primary
)
}
}
) {
GuestDetailRow(label = "Amount Collected", value = details?.amountCollected?.toString()) GuestDetailRow(label = "Amount Collected", value = details?.amountCollected?.toString())
GuestDetailRow(label = "Rent per day", value = details?.totalNightlyRate?.toString()) GuestDetailRow(label = "Rent per day", value = details?.totalNightlyRate?.toString())
GuestDetailRow(label = "Expected pay", value = details?.expectedPay?.toString()) GuestDetailRow(label = "Expected pay", value = details?.expectedPay?.toString())
@@ -263,13 +300,24 @@ private fun GuestDetailRow(label: String, value: String?) {
} }
@Composable @Composable
private fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) { private fun SectionCard(
title: String,
headerContent: (@Composable RowScope.() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit
) {
Card( Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Text(text = title, style = MaterialTheme.typography.titleMedium) Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.weight(1f))
headerContent?.invoke(this)
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
content() content()
} }

View File

@@ -22,6 +22,7 @@ coilSvg = "2.7.0"
lottieCompose = "6.7.1" lottieCompose = "6.7.1"
calendarCompose = "2.6.0" calendarCompose = "2.6.0"
libphonenumber = "8.13.34" libphonenumber = "8.13.34"
zxingCore = "3.5.3"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -54,6 +55,7 @@ coil-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coilSvg" }
lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottieCompose" } lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottieCompose" }
calendar-compose = { group = "com.kizitonwose.calendar", name = "compose", version.ref = "calendarCompose" } calendar-compose = { group = "com.kizitonwose.calendar", name = "compose", version.ref = "calendarCompose" }
libphonenumber = { group = "com.googlecode.libphonenumber", name = "libphonenumber", version.ref = "libphonenumber" } libphonenumber = { group = "com.googlecode.libphonenumber", name = "libphonenumber", version.ref = "libphonenumber" }
zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxingCore" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }