diff --git a/app/src/main/java/com/example/talkeys_new/screens/payment/EventPaymentScreen.kt b/app/src/main/java/com/example/talkeys_new/screens/payment/EventPaymentScreen.kt index c0a898a..d70420c 100644 --- a/app/src/main/java/com/example/talkeys_new/screens/payment/EventPaymentScreen.kt +++ b/app/src/main/java/com/example/talkeys_new/screens/payment/EventPaymentScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -22,7 +21,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController import com.example.talkeys_new.R -import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -32,7 +30,6 @@ fun EventPaymentScreen( eventPrice: String, navController: NavController ) { - val context = LocalContext.current val scrollState = rememberScrollState() // Convert price to double for calculations @@ -234,9 +231,7 @@ private fun PhonePePaymentSection( navController: NavController, onPaymentInitiated: () -> Unit ) { - val context = LocalContext.current var isLoading by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() val paymentViewModel = sharedPaymentCheckoutViewModel() val checkoutState by paymentViewModel.checkoutState.collectAsState() val errorMessage = checkoutState.errorMessage @@ -355,21 +350,13 @@ private fun PhonePePaymentSection( val passType = determinePassType(amount) val friends = getUserSelectedFriends() - scope.launch { - val tokenManager = com.example.talkeys_new.screens.authentication.TokenManager(context) - val tokenResult = tokenManager.getToken() - val authToken = when (tokenResult) { - is com.example.talkeys_new.utils.Result.Success -> tokenResult.data?.takeIf { it.isNotBlank() } - else -> null - } - - paymentViewModel.startCheckout( - eventId = eventId, - passType = passType, - friends = friends, - authToken = authToken - ) - } + paymentViewModel.startCheckout( + eventId = eventId, + passType = passType, + friends = friends, + teamCode = null, + authToken = null + ) }, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/example/talkeys_new/screens/payment/WebViewPaymentScreen.kt b/app/src/main/java/com/example/talkeys_new/screens/payment/WebViewPaymentScreen.kt index f1dcb5f..86d0f10 100644 --- a/app/src/main/java/com/example/talkeys_new/screens/payment/WebViewPaymentScreen.kt +++ b/app/src/main/java/com/example/talkeys_new/screens/payment/WebViewPaymentScreen.kt @@ -2,6 +2,7 @@ package com.example.talkeys_new.screens.payment import android.annotation.SuppressLint import android.graphics.Bitmap +import android.content.Intent import android.net.http.SslError import android.webkit.* import androidx.compose.foundation.layout.* @@ -94,7 +95,12 @@ fun WebViewPaymentScreen( // WebView AndroidView( factory = { context -> - WebView(context).apply { + WebView(context).apply webView@{ + CookieManager.getInstance().apply { + setAcceptCookie(true) + setAcceptThirdPartyCookies(this@webView, true) + } + settings.apply { javaScriptEnabled = true domStorageEnabled = true @@ -104,7 +110,15 @@ fun WebViewPaymentScreen( loadWithOverviewMode = true useWideViewPort = true javaScriptCanOpenWindowsAutomatically = true + setSupportMultipleWindows(true) mediaPlaybackRequiresUserGesture = false + loadsImagesAutomatically = true + cacheMode = WebSettings.LOAD_DEFAULT + allowContentAccess = true + userAgentString = userAgentString + ?.replace("; wv", "") + ?.replace("Version/4.0 ", "") + ?: userAgentString // Allow mixed content for payment gateways mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW @@ -122,7 +136,7 @@ fun WebViewPaymentScreen( super.onPageFinished(view, url) isLoading = false currentUrl = url ?: "" - Log.d("WebViewPayment", "Payment page finished loading") + Log.d("WebViewPayment", "Payment page finished loading for host=${runCatching { android.net.Uri.parse(url).host }.getOrNull() ?: "unknown"}") // Check for session expired error view?.evaluateJavascript( @@ -218,6 +232,24 @@ fun WebViewPaymentScreen( isLoading = false } } + + override fun onCreateWindow( + view: WebView?, + isDialog: Boolean, + isUserGesture: Boolean, + resultMsg: android.os.Message? + ): Boolean { + val popupUrl = view?.hitTestResult?.extra + if (!popupUrl.isNullOrBlank()) { + runCatching { + context.startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(popupUrl))) + }.onFailure { error -> + Log.e("WebViewPayment", "Failed to open popup URL: ${error.message}") + } + return false + } + return super.onCreateWindow(view, isDialog, isUserGesture, resultMsg) + } override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean = true } diff --git a/app/src/main/java/com/example/talkeys_new/screens/profile/ProfileScreen.kt b/app/src/main/java/com/example/talkeys_new/screens/profile/ProfileScreen.kt index 4f83d3b..e189a56 100644 --- a/app/src/main/java/com/example/talkeys_new/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/com/example/talkeys_new/screens/profile/ProfileScreen.kt @@ -74,13 +74,20 @@ fun ProfileScreen(navController: NavController) { val userProfile = remember(sharedProfileState, localGoogleProfile) { val content = sharedProfileState as? ProfileUiState.Content content?.profile?.let { profile -> + val resolvedDisplayName = firstNonBlank( + profile.displayName, + profile.name, + localGoogleProfile.givenName, + localGoogleProfile.name + ) + val resolvedEmail = firstNonBlank(profile.email, localGoogleProfile.email) UserProfile( - id = profile.id, - name = profile.displayName ?: profile.name, - email = profile.email, - profileImageUrl = profile.avatarUrl, - givenName = profile.displayName ?: profile.name, - familyName = "" + id = firstNonBlank(profile.id, localGoogleProfile.id), + name = resolvedDisplayName, + email = resolvedEmail, + profileImageUrl = profile.avatarUrl ?: localGoogleProfile.profileImageUrl, + givenName = resolvedDisplayName, + familyName = localGoogleProfile.familyName ) } ?: localGoogleProfile } @@ -951,6 +958,10 @@ private fun getUserDisplayName(userProfile: UserProfile): String { } } +private fun firstNonBlank(vararg values: String?): String { + return values.firstOrNull { !it.isNullOrBlank() }?.trim().orEmpty() +} + private fun getUserEmail(userProfile: UserProfile): String { return if (userProfile.email.isNotEmpty()) { "@${userProfile.email.substringBefore("@")}" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9d81c95..895b20d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,6 @@ TalkeysApk + 563385258779-75kq583ov98fk7h3dqp5em0639769a61.apps.googleusercontent.com fcm_default_channel FCM Registration Token: %1$s - \ No newline at end of file + diff --git a/shared/src/commonMain/kotlin/com/talkeys/shared/data/payment/PaymentModels.kt b/shared/src/commonMain/kotlin/com/talkeys/shared/data/payment/PaymentModels.kt index 39dc470..a40b541 100644 --- a/shared/src/commonMain/kotlin/com/talkeys/shared/data/payment/PaymentModels.kt +++ b/shared/src/commonMain/kotlin/com/talkeys/shared/data/payment/PaymentModels.kt @@ -1,12 +1,16 @@ package com.talkeys.shared.data.payment +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.JsonNames @Serializable data class BookTicketRequest( val eventId: String, val passType: String, - val friends: List + val friends: List, + val teamCode: String? = null ) @Serializable @@ -23,18 +27,27 @@ data class BookTicketResponse( ) @Serializable +@OptIn(ExperimentalSerializationApi::class) data class PaymentOrderData( val passId: String, val merchantOrderId: String, - val orderId: String, // Backend sends "orderId" not "phonePeOrderId" - val amount: Int, - val amountInPaisa: Int, - val totalTickets: Int, - val token: String, // Backend sends "token" not "paymentUrl" - val event: EventInfo, - val qrStrings: List, - val friends: List -) + @JsonNames("orderId", "phonePeOrderId") + val orderId: String? = null, + val amount: Int? = null, + val amountInPaisa: Int? = null, + val totalTickets: Int? = null, + @JsonNames("token", "paymentToken", "payment_token") + val token: String? = null, + @SerialName("paymentUrl") + @JsonNames("paymentUrl", "checkoutUrl", "redirectUrl", "url", "redirectURL") + val paymentUrl: String? = null, + val event: EventInfo? = null, + val qrStrings: List = emptyList(), + val friends: List = emptyList() +) { + fun checkoutTokenOrUrl(): String? = paymentUrl?.takeIf { it.isNotBlank() } + ?: token?.takeIf { it.isNotBlank() } +} @Serializable data class EventInfo( @@ -58,4 +71,4 @@ data class PaymentStatusData( val passId: String, val passUUID: String? = null, // Make optional since backend may not always include it val paymentStatus: String -) \ No newline at end of file +) diff --git a/shared/src/commonMain/kotlin/com/talkeys/shared/data/payment/PaymentRepository.kt b/shared/src/commonMain/kotlin/com/talkeys/shared/data/payment/PaymentRepository.kt index 88f2931..38468ca 100644 --- a/shared/src/commonMain/kotlin/com/talkeys/shared/data/payment/PaymentRepository.kt +++ b/shared/src/commonMain/kotlin/com/talkeys/shared/data/payment/PaymentRepository.kt @@ -2,9 +2,13 @@ package com.talkeys.shared.data.payment import com.talkeys.shared.network.PaymentApiService import com.talkeys.shared.config.ProductionConfig +import com.talkeys.shared.auth.TokenStorage import co.touchlab.kermit.Logger -open class PaymentRepository(private val paymentApiService: PaymentApiService) { +open class PaymentRepository( + private val paymentApiService: PaymentApiService, + private val tokenStorage: TokenStorage? = null +) { private val logger = Logger.withTag("PaymentRepository") @@ -22,6 +26,7 @@ open class PaymentRepository(private val paymentApiService: PaymentApiService) { eventId: String, passType: String, friends: List, + teamCode: String? = null, authToken: String? = null ): Result { // Production validation @@ -29,16 +34,20 @@ open class PaymentRepository(private val paymentApiService: PaymentApiService) { return Result.failure(Exception("Maximum ${ProductionConfig.MAX_FRIENDS_PER_BOOKING} friends allowed per booking")) } + val resolvedAuthToken = resolveAuthToken(authToken) + ?: return Result.failure(Exception("Please login to continue")) + logger.i { "Production: Booking ticket for event: $eventId, passType: $passType, friends: ${friends.size}" } val request = BookTicketRequest( eventId = eventId, passType = passType, - friends = friends + friends = friends, + teamCode = teamCode?.trim()?.takeIf { it.isNotEmpty() } ) return try { - val result = paymentApiService.bookTicketApp(request, authToken) + val result = paymentApiService.bookTicketApp(request, resolvedAuthToken) result.fold( onSuccess = { response -> @@ -66,10 +75,13 @@ open class PaymentRepository(private val paymentApiService: PaymentApiService) { * Verify payment status after PhonePe payment completion */ open suspend fun verifyPaymentStatus(merchantOrderId: String, authToken: String? = null): Result { + val resolvedAuthToken = resolveAuthToken(authToken) + ?: return Result.failure(Exception("Please login to verify payment")) + logger.d { "Verifying payment status" } return try { - val result = paymentApiService.checkPaymentStatus(merchantOrderId, authToken) + val result = paymentApiService.checkPaymentStatus(merchantOrderId, resolvedAuthToken) result.fold( onSuccess = { response -> @@ -120,4 +132,9 @@ open class PaymentRepository(private val paymentApiService: PaymentApiService) { Result.failure(e) } } + + private suspend fun resolveAuthToken(authToken: String?): String? { + return authToken?.trim()?.takeIf { it.isNotBlank() } + ?: tokenStorage?.getToken()?.trim()?.takeIf { it.isNotBlank() } + } } diff --git a/shared/src/commonMain/kotlin/com/talkeys/shared/di/SharedModule.kt b/shared/src/commonMain/kotlin/com/talkeys/shared/di/SharedModule.kt index 8a8a478..7c17adf 100644 --- a/shared/src/commonMain/kotlin/com/talkeys/shared/di/SharedModule.kt +++ b/shared/src/commonMain/kotlin/com/talkeys/shared/di/SharedModule.kt @@ -14,7 +14,7 @@ import org.koin.dsl.module val sharedModule = module { single { ApiClient() } single { PaymentApiService(get()) } - single { PaymentRepository(get()) } + single { PaymentRepository(get(), get()) } // Phase 4 — Events vertical slice (read-only) single { EventsApi(get()) } diff --git a/shared/src/commonMain/kotlin/com/talkeys/shared/network/PaymentApiService.kt b/shared/src/commonMain/kotlin/com/talkeys/shared/network/PaymentApiService.kt index c89e536..b73b5d4 100644 --- a/shared/src/commonMain/kotlin/com/talkeys/shared/network/PaymentApiService.kt +++ b/shared/src/commonMain/kotlin/com/talkeys/shared/network/PaymentApiService.kt @@ -4,19 +4,33 @@ import com.talkeys.shared.data.payment.BookTicketRequest import com.talkeys.shared.data.payment.BookTicketResponse import com.talkeys.shared.data.payment.PaymentStatusResponse import io.ktor.client.call.* +import io.ktor.client.statement.bodyAsText import io.ktor.client.plugins.timeout import io.ktor.client.request.* import io.ktor.http.* +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive class PaymentApiService(private val apiClient: ApiClient) { + private val json = Json { + isLenient = true + ignoreUnknownKeys = true + } /** * Book ticket and create payment order - * POST /api/book-ticket-app + * POST /api/book-ticket */ suspend fun bookTicketApp(request: BookTicketRequest, authToken: String? = null): Result { return try { - val response = apiClient.httpClient.post("${ApiClient.BASE_URL}/api/book-ticket-app") { + val response = apiClient.httpClient.post("${ApiClient.BASE_URL}/api/book-ticket") { contentType(ContentType.Application.Json) authToken?.let { token -> header("Authorization", "Bearer $token") @@ -28,9 +42,28 @@ class PaymentApiService(private val apiClient: ApiClient) { } if (response.status.isSuccess()) { - Result.success(response.body()) + val rawBody = response.bodyAsText() + val parsed = try { + json.decodeFromString(rawBody) + } catch (exception: SerializationException) { + return Result.failure( + Exception( + buildString { + append("Unable to read payment response") + val shape = describePayloadShape(rawBody) + if (shape != null) { + append(": ") + append(shape) + } + }, + exception + ) + ) + } + Result.success(parsed) } else { - Result.failure(Exception("HTTP ${response.status.value}: ${response.status.description}")) + val errorBody = runCatching { response.bodyAsText() }.getOrNull() + Result.failure(Exception(formatHttpError(response.status, errorBody))) } } catch (e: Exception) { Result.failure(e) @@ -60,10 +93,70 @@ class PaymentApiService(private val apiClient: ApiClient) { val statusResponse = response.body() Result.success(statusResponse) } else { - Result.failure(Exception("HTTP ${response.status.value}: ${response.status.description}")) + val errorBody = runCatching { response.bodyAsText() }.getOrNull() + Result.failure(Exception(formatHttpError(response.status, errorBody))) } } catch (e: Exception) { Result.failure(e) } } -} \ No newline at end of file + + private fun formatHttpError(status: HttpStatusCode, body: String?): String { + val trimmedBody = body?.trim()?.takeIf { it.isNotEmpty() } + return buildString { + append("HTTP ${status.value}: ${status.description}") + if (trimmedBody != null) { + append(" - ") + append(trimmedBody) + } + } + } + + private fun describePayloadShape(rawBody: String): String? { + val root = runCatching { json.parseToJsonElement(rawBody).jsonObject }.getOrNull() ?: return null + val data = root["data"] as? JsonObject + val dataKeys = data?.keys?.sorted().orEmpty() + + val success = (root["success"] as? JsonPrimitive)?.booleanOrNull + val message = (root["message"] as? JsonPrimitive)?.stringOrNull() + + return buildString { + append("response keys=") + append(root.keys.sorted()) + if (success != null) { + append(", success=") + append(success) + } + if (!message.isNullOrBlank()) { + append(", message=") + append(message) + } + if (data != null) { + append(", data keys=") + append(dataKeys) + val merchantOrderId = (data["merchantOrderId"] as? JsonPrimitive)?.stringOrNull() + val passId = (data["passId"] as? JsonPrimitive)?.stringOrNull() + val hasToken = (data["token"] as? JsonPrimitive)?.stringOrNull()?.isNotBlank() == true + val hasPaymentUrl = listOf("paymentUrl", "checkoutUrl", "redirectUrl", "url") + .any { key -> (data[key] as? JsonPrimitive)?.stringOrNull()?.isNotBlank() == true } + val amount = (data["amount"] as? JsonPrimitive)?.intOrNull + if (!merchantOrderId.isNullOrBlank()) { + append(", merchantOrderId present") + } + if (!passId.isNullOrBlank()) { + append(", passId present") + } + if (amount != null) { + append(", amount=") + append(amount) + } + append(", hasToken=") + append(hasToken) + append(", hasPaymentUrl=") + append(hasPaymentUrl) + } + } + } + + private fun JsonPrimitive.stringOrNull(): String? = runCatching { content }.getOrNull() +} diff --git a/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/payment/PaymentCheckoutViewModel.kt b/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/payment/PaymentCheckoutViewModel.kt index 7232500..9dbcb17 100644 --- a/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/payment/PaymentCheckoutViewModel.kt +++ b/shared/src/commonMain/kotlin/com/talkeys/shared/presentation/payment/PaymentCheckoutViewModel.kt @@ -25,21 +25,31 @@ class PaymentCheckoutViewModel( eventId: String, passType: String, friends: List, + teamCode: String? = null, authToken: String? ) { - if (authToken.isNullOrBlank()) { - _checkoutState.value = PaymentCheckoutUiState(errorMessage = "Please login to continue") - return - } - _checkoutState.value = PaymentCheckoutUiState(isLoading = true) viewModelScope.launch { - repository.bookTicket(eventId, passType, friends, authToken) + repository.bookTicket( + eventId = eventId, + passType = passType, + friends = friends, + teamCode = teamCode, + authToken = authToken + ) .onSuccess { paymentOrder -> + val checkoutTokenOrUrl = paymentOrder.checkoutTokenOrUrl() + if (checkoutTokenOrUrl.isNullOrBlank()) { + _checkoutState.value = PaymentCheckoutUiState( + errorMessage = "Payment response did not include checkout details. Please try again." + ) + return@onSuccess + } + _checkoutState.value = PaymentCheckoutUiState( checkoutData = PaymentCheckoutData( paymentUrl = PhonePeCheckoutUrlBuilder.buildCheckoutUrl( - tokenOrUrl = paymentOrder.token, + tokenOrUrl = checkoutTokenOrUrl, isProduction = isPhonePeProduction ), merchantOrderId = paymentOrder.merchantOrderId, @@ -56,11 +66,6 @@ class PaymentCheckoutViewModel( } fun verifyPaymentStatus(merchantOrderId: String, authToken: String?) { - if (authToken.isNullOrBlank()) { - _verificationState.value = PaymentVerificationUiState(errorMessage = "Please login to verify payment") - return - } - _verificationState.value = PaymentVerificationUiState(isLoading = true) viewModelScope.launch { repository.verifyPaymentStatus(merchantOrderId, authToken) diff --git a/shared/src/commonTest/kotlin/com/talkeys/shared/presentation/payment/PaymentCheckoutViewModelTest.kt b/shared/src/commonTest/kotlin/com/talkeys/shared/presentation/payment/PaymentCheckoutViewModelTest.kt index 8068a61..6aed393 100644 --- a/shared/src/commonTest/kotlin/com/talkeys/shared/presentation/payment/PaymentCheckoutViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/talkeys/shared/presentation/payment/PaymentCheckoutViewModelTest.kt @@ -34,9 +34,12 @@ class PaymentCheckoutViewModelTest { @Test fun startCheckout_requiresAuthToken() = runTest { - val viewModel = PaymentCheckoutViewModel(fakeRepository(Result.success(orderData()))) + val viewModel = PaymentCheckoutViewModel( + fakeRepository(Result.failure(Exception("Please login to continue"))) + ) viewModel.startCheckout("event-1", "General", emptyList(), authToken = null) + advanceUntilIdle() assertFalse(viewModel.checkoutState.value.isLoading) assertEquals("Please login to continue", viewModel.checkoutState.value.errorMessage) @@ -58,6 +61,36 @@ class PaymentCheckoutViewModelTest { assertEquals("https://mercury-t2.phonepe.com/transact/pg?token=token%2Babc", checkout.paymentUrl) } + @Test + fun startCheckout_usesFullPaymentUrlWhenBackendReturnsIt() = runTest { + val paymentUrl = "https://mercury.phonepe.com/transact/pg?token=abc123" + val viewModel = PaymentCheckoutViewModel( + repository = fakeRepository(Result.success(orderData(token = null, paymentUrl = paymentUrl))), + isPhonePeProduction = true + ) + + viewModel.startCheckout("event-1", "General", emptyList(), authToken = "jwt") + advanceUntilIdle() + + val checkout = assertNotNull(viewModel.checkoutState.value.checkoutData) + assertEquals(paymentUrl, checkout.paymentUrl) + } + + @Test + fun startCheckout_failsWhenBackendOmitsCheckoutDetails() = runTest { + val viewModel = PaymentCheckoutViewModel( + repository = fakeRepository(Result.success(orderData(token = null, paymentUrl = null))) + ) + + viewModel.startCheckout("event-1", "General", emptyList(), authToken = "jwt") + advanceUntilIdle() + + assertEquals( + "Payment response did not include checkout details. Please try again.", + viewModel.checkoutState.value.errorMessage + ) + } + @Test fun verifyPaymentStatus_setsCompletedStatus() = runTest { val viewModel = PaymentCheckoutViewModel( @@ -80,11 +113,12 @@ class PaymentCheckoutViewModelTest { val viewModel = PaymentCheckoutViewModel( repository = fakeRepository( bookTicketResult = Result.success(orderData()), - statusResult = Result.success(PaymentStatusData("pass-1", "uuid-1", "COMPLETED")) + statusResult = Result.failure(Exception("Please login to verify payment")) ) ) viewModel.verifyPaymentStatus("merchant-1", authToken = null) + advanceUntilIdle() assertFalse(viewModel.verificationState.value.isLoading) assertEquals("Please login to verify payment", viewModel.verificationState.value.errorMessage) @@ -98,6 +132,7 @@ class PaymentCheckoutViewModelTest { eventId: String, passType: String, friends: List, + teamCode: String?, authToken: String? ): Result = bookTicketResult @@ -107,7 +142,10 @@ class PaymentCheckoutViewModelTest { ): Result = statusResult } - private fun orderData(token: String = "token") = PaymentOrderData( + private fun orderData( + token: String? = "token", + paymentUrl: String? = null + ) = PaymentOrderData( passId = "pass-1", merchantOrderId = "merchant-1", orderId = "order-1", @@ -115,6 +153,7 @@ class PaymentCheckoutViewModelTest { amountInPaisa = 10000, totalTickets = 1, token = token, + paymentUrl = paymentUrl, event = EventInfo(id = "event-1"), qrStrings = emptyList(), friends = emptyList()