diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreenTest.kt index bc1acc5d41..046befbeac 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreenTest.kt @@ -369,4 +369,40 @@ class SendConfirmScreenTest { composeTestRule.onNodeWithText("±10-20 minutes").performScrollTo().assertIsDisplayed() composeTestRule.onNodeWithText("Depends on the fee").assertDoesNotExist() } + + @Test + fun merchantRequestLabelsThePayeeAsTo() { + val seller = PubkyProfile.placeholder("seller").copy(name = "Corner Shop") + val state = mutableStateOf( + SendUiState( + amount = 25_000u, + address = "bcrt1qkgfgyxyqhvkdqh04sklnzxphmcds6vft6y7h0r", + isAmountInputValid = true, + payMethod = SendMethod.ONCHAIN, + isPaymentRequest = true, + isMerchantPaymentRequest = true, + contactPaymentProfile = seller, + paymentRequestNote = "Pubky Shop order 1a2b3c4d", + ) + ) + composeTestRule.setContent { + AppThemeSurface { + SendConfirmContent( + uiState = state.value, + isNodeRunning = true, + isLoading = false, + showBiometrics = false, + modifier = Modifier.sheetHeight(), + ) + } + } + + composeTestRule.onNodeWithTag("SendConfirmToggleDetails").performClick() + composeTestRule.onNodeWithText("To", ignoreCase = true).performScrollTo().assertIsDisplayed() + composeTestRule.onNodeWithText("Contact", ignoreCase = true).assertDoesNotExist() + + composeTestRule.runOnIdle { state.value = state.value.copy(isMerchantPaymentRequest = false) } + composeTestRule.onNodeWithText("Contact", ignoreCase = true).performScrollTo().assertIsDisplayed() + composeTestRule.onNodeWithText("To", ignoreCase = true).assertDoesNotExist() + } } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 000f9e57bb..4fed4423a5 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -106,6 +106,8 @@ data class PaykitPaymentRequest( val paymentDeadlineAt: Instant? = null, val isPaymentInFlight: Boolean = false, val proposalStreamItemId: ULong? = null, + /** An incoming request a Paykit server created for a merchant, such as a Pubky Market order. */ + val isMerchantRequest: Boolean = false, ) { val amountValue: String get() = amount.value @@ -2036,8 +2038,12 @@ private fun PaymentRequestRecord.toPaykitPaymentRequest( paymentProofKind = paymentProofs.lastOrNull()?.let { PaykitPaymentProofKind.fromPaymentEndpointIdentifier(it.paymentEndpointIdentifier) }, + isMerchantRequest = expectedRole == PaymentRequestLocalRole.PAYER && proposalAppId == PAYKIT_SERVER_APP_ID, ) +/** The app id a Paykit server stamps on requests it creates for merchants. */ +private const val PAYKIT_SERVER_APP_ID = "paykit-server" + internal fun PaymentRequestRecord.toPaykitPaymentRequestHistory(now: Instant): PaykitPaymentRequest? { val role = localRole ?: return null if (role == PaymentRequestLocalRole.UNKNOWN) return null diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt index abd80bbfcd..118618c4af 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt @@ -187,7 +187,16 @@ internal fun IncomingPaymentRequestDetailsContent( ) } VerticalSpacer(20.dp) - Caption13Up(text = stringResource(R.string.wallet__payment_request_contact), color = Colors.White64) + Caption13Up( + text = stringResource( + if (request.isMerchantRequest) { + R.string.wallet__send_to + } else { + R.string.wallet__payment_request_contact + } + ), + color = Colors.White64, + ) VerticalSpacer(8.dp) Row( verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt index 991e3134ff..8d324c22e9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt @@ -602,7 +602,7 @@ private fun OnChainDetails( } SendCell( caption = stringResource( - if (uiState.isOneOffPaymentRequest) { + if (uiState.isOneOffPaymentRequest && !uiState.isMerchantPaymentRequest) { R.string.wallet__payment_request_contact } else { R.string.wallet__send_to @@ -753,7 +753,7 @@ private fun LightningDetails( } SendCell( caption = stringResource( - if (uiState.isOneOffPaymentRequest) { + if (uiState.isOneOffPaymentRequest && !uiState.isMerchantPaymentRequest) { R.string.wallet__payment_request_contact } else { R.string.wallet__send_to diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/PaykitUsdtReview.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/PaykitUsdtReview.kt index 98e447daf6..311459c900 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/PaykitUsdtReview.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/PaykitUsdtReview.kt @@ -127,6 +127,7 @@ fun PaykitUsdtReview( amountEditable = false, isPaymentRequest = uiState.isPaymentRequest, paymentRequestNote = uiState.paymentRequestNote, + isMerchantRequest = uiState.isMerchantPaymentRequest, contact = uiState.contactPaymentProfile, ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtSendContent.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtSendContent.kt index a29ba8e65f..94376557ec 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtSendContent.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtSendContent.kt @@ -123,6 +123,7 @@ internal fun UsdtPaymentContent( amountEditable: Boolean = true, isPaymentRequest: Boolean = false, paymentRequestNote: String? = null, + isMerchantRequest: Boolean = false, contact: PubkyProfile? = null, onDestinationChange: ((UsdtDestination) -> Unit)? = null, destinations: ImmutableList = Env.usdtDestinations.toImmutableList(), @@ -209,6 +210,7 @@ internal fun UsdtPaymentContent( state, isPaymentRequest, paymentRequestNote, + isMerchantRequest, contact, onConfirm, completed, @@ -536,6 +538,7 @@ private fun UsdtConfirmation( state: UsdtSendState, isPaymentRequest: Boolean, paymentRequestNote: String?, + isMerchantRequest: Boolean, contact: PubkyProfile?, onConfirm: () -> Unit, completed: Boolean, @@ -557,6 +560,7 @@ private fun UsdtConfirmation( contact, note, isPaymentRequest, + isMerchantRequest, showDetails, ) { if (!state.busy && !completed) onEditAmount() @@ -643,6 +647,7 @@ private fun UsdtReviewHeader( contact: PubkyProfile?, note: String?, isPaymentRequest: Boolean, + isMerchantRequest: Boolean, showDetails: Boolean, modifier: Modifier = Modifier, onEditAmount: () -> Unit, @@ -655,7 +660,7 @@ private fun UsdtReviewHeader( ) VerticalSpacer(24.dp) if (showDetails) { - UsdtQuoteDetails(quote, contact) + UsdtQuoteDetails(quote, contact, isMerchantRequest) note?.let { VerticalSpacer(16.dp) PaymentRequestInvoiceNote(it) @@ -668,7 +673,7 @@ private fun UsdtReviewHeader( } @Composable -private fun UsdtQuoteDetails(quote: UsdtQuote, contact: PubkyProfile?) { +private fun UsdtQuoteDetails(quote: UsdtQuote, contact: PubkyProfile?, isMerchantRequest: Boolean) { var showFullAddress by rememberSaveable(quote.recipient) { mutableStateOf(false) } Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { UsdtDetailField(caption = stringResource(R.string.usdt__pay_from), modifier = Modifier.weight(1f)) { @@ -723,7 +728,15 @@ private fun UsdtQuoteDetails(quote: UsdtQuote, contact: PubkyProfile?) { VerticalSpacer(16.dp) } if (contact != null) { - SendCell(caption = stringResource(R.string.wallet__payment_request_contact)) { + SendCell( + caption = stringResource( + if (isMerchantRequest) { + R.string.wallet__send_to + } else { + R.string.wallet__payment_request_contact + } + ) + ) { Row(verticalAlignment = Alignment.CenterVertically) { PubkyContactAvatar(profile = contact, size = 20.dp) HorizontalSpacer(4.dp) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 4f39e05f31..63b5e10f6f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1627,6 +1627,7 @@ class AppViewModel @Inject constructor( resetSendState( isPaymentRequest = true, paymentRequestNote = request.note, + isMerchantPaymentRequest = request.isMerchantRequest, hardwareWalletId = proof.onchainWalletId, incomingPaymentRequestId = request.id, selectedTags = requestedPaymentRequestTags, @@ -4267,6 +4268,7 @@ class AppViewModel @Inject constructor( contactPaymentProfile = contactPaymentProfile, isPaymentRequest = isPaymentRequest, paymentRequestNote = incomingPaymentRequest?.note, + isMerchantPaymentRequest = incomingPaymentRequest?.isMerchantRequest == true, isSubscriptionPayment = incomingPaymentRequest?.billingPeriod != null, isInitialSubscriptionPayment = synchronized(contactPaymentContextLock) { activeContactPaymentContext?.isInitialSubscriptionPayment == true @@ -6622,6 +6624,7 @@ class AppViewModel @Inject constructor( contactPaymentProfile: PubkyProfile? = null, isPaymentRequest: Boolean = false, paymentRequestNote: String? = null, + isMerchantPaymentRequest: Boolean = false, hardwareWalletId: String? = activeHardwareWalletId, isSubscriptionPayment: Boolean = false, isInitialSubscriptionPayment: Boolean = false, @@ -6648,6 +6651,7 @@ class AppViewModel @Inject constructor( contactPaymentProfile = contactPaymentProfile, isPaymentRequest = isPaymentRequest, paymentRequestNote = paymentRequestNote, + isMerchantPaymentRequest = isMerchantPaymentRequest, hardwareWalletId = hardwareWalletId, hardwareWalletName = hardwareWalletId?.let { walletId -> hwWalletRepo.wallets.value.find { it.id == walletId }?.name @@ -8109,6 +8113,7 @@ data class SendUiState( val contactPaymentProfile: PubkyProfile? = null, val isPaymentRequest: Boolean = false, val paymentRequestNote: String? = null, + val isMerchantPaymentRequest: Boolean = false, val paykitUsesUsdt: Boolean = false, val paykitRateUnavailable: Boolean = false, val paykitAmount: String = "", diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index ae6c13ccd4..a10832ff64 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -988,6 +988,18 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(listOf(MethodId.Bolt11.rawValue), request.acceptedPaymentEndpointIdentifiers) } + @Test + fun `requests created by a paykit server are merchant requests`() { + fun parse(record: PaymentRequestRecord) = + (record.parseIncomingPaykitPaymentRequest(START_TIME) as PaykitPaymentRequestParseResult.Parsed).request + + assertFalse(parse(paymentRequestRecord()).isMerchantRequest) + assertTrue(parse(paymentRequestRecord().copy(proposalAppId = "paykit-server")).isMerchantRequest) + assertFalse(parse(paymentRequestRecord().copy(proposalAppId = "marketplace")).isMerchantRequest) + val outgoing = paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE).copy(proposalAppId = "paykit-server") + assertFalse(requireNotNull(outgoing.toPaykitPaymentRequestHistory(START_TIME)).isMerchantRequest) + } + @Test fun `absolute payment deadlines preserve fractional seconds and include the deadline instant`() { val deadline = START_TIME + 123.milliseconds diff --git a/changelog.d/next/1625.changed.md b/changelog.d/next/1625.changed.md new file mode 100644 index 0000000000..56bf5f63a0 --- /dev/null +++ b/changelog.d/next/1625.changed.md @@ -0,0 +1 @@ +Payment requests from a shop now show the payee under To instead of Contact. diff --git a/journeys/payment-requests/issuer-interoperability.xml b/journeys/payment-requests/issuer-interoperability.xml index a2fa009c47..1c9e9d3358 100644 --- a/journeys/payment-requests/issuer-interoperability.xml +++ b/journeys/payment-requests/issuer-interoperability.xml @@ -13,6 +13,7 @@ Verify the one-time request row (testTag "PaymentRequestRow-71300000-0000-4000-8000-000000000001") shows 100,000 sats Tap the one-time request row (testTag "PaymentRequestRow-71300000-0000-4000-8000-000000000001") Verify Payment Request details (testTag "PaymentRequestDetailsScreen") shows an unsigned 100,000 sats amount (testTag "PaymentRequestDetailsAmount") and status "Waiting for payment" (testTag "PaymentRequestDetailsStatus") + Verify the requester heading on Payment Request details reads "To" Tap Back (testTag "NavigationBack") Tap the pending Payment Requests bell (testTag "PaymentRequestsBell") Verify the incoming Payment Requests sheet (testTag "PaymentRequestsSheet") appears @@ -21,6 +22,6 @@ Verify the Payment Request confirmation screen (testTag "PaymentRequestConfirm") shows 100,000 sats Wait for fresh validation and wallet preparation to finish; verify the same sheet stays open and its payment swipe control stops showing progress Tap Show details (testTag "SendConfirmToggleDetails") - Verify the recipient (testTag "ReviewContactRecipient") is the saved fixture issuer contact + Verify the recipient (testTag "ReviewContactRecipient") under To is the saved fixture issuer contact; the fixture is a Paykit server request, so the label reads "To" rather than "Contact"