From 2c486a94d1ed3a7b57cd4d12b9a2925658419946 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 18 Oct 2026 18:59:46 -0600 Subject: [PATCH 1/7] fix: reuse initial USDT subscription authorization --- .../subscriptions/SubscriptionsScreen.kt | 32 +++-- .../screens/wallets/send/SendConfirmScreen.kt | 2 +- .../screens/wallets/usdt/PaykitUsdtReview.kt | 109 +++++++++++------- .../bitkit/ui/sheets/NewTransactionSheet.kt | 2 +- .../java/to/bitkit/ui/sheets/SendSheet.kt | 22 +++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 39 ++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 67 ++++++++++- changelog.d/next/1540.fixed.md | 1 + .../subscriptions/review-and-subscribe.xml | 12 +- 9 files changed, 221 insertions(+), 65 deletions(-) create mode 100644 changelog.d/next/1540.fixed.md diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt index 52049e6860..9a836887fe 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt @@ -66,6 +66,8 @@ import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.ext.dateTimeFormatterOf import to.bitkit.ext.nowMillis +import to.bitkit.models.NewTransactionSheetDetails +import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.PaykitAmount import to.bitkit.models.PaykitAsset @@ -111,6 +113,7 @@ import to.bitkit.ui.screens.wallets.activity.components.CustomTabRowWithSpacing import to.bitkit.ui.screens.wallets.activity.components.TabItem import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.sheets.TransactionConfetti import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.removeAccentTags import to.bitkit.ui.utils.withAccent @@ -909,21 +912,32 @@ fun SubscriptionSuccess( onClose: () -> Unit, paymentType: NewTransactionSheetType?, modifier: Modifier = Modifier, + usdtAmount: ULong? = null, ) { Box( modifier = modifier .fillMaxSize() .navigationBarsPadding() ) { - val composition by rememberLottieComposition( - LottieCompositionSpec.RawRes(subscriptionConfettiResource(paymentType)) - ) - LottieAnimation( - composition = composition, - contentScale = ContentScale.Crop, - iterations = 100, - modifier = Modifier.fillMaxSize() - ) + if (usdtAmount != null) { + TransactionConfetti( + NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.SENT, + usdtAmount = usdtAmount, + ) + ) + } else { + val composition by rememberLottieComposition( + LottieCompositionSpec.RawRes(subscriptionConfettiResource(paymentType)) + ) + LottieAnimation( + composition = composition, + contentScale = ContentScale.Crop, + iterations = 100, + modifier = Modifier.fillMaxSize() + ) + } Column( modifier = Modifier .fillMaxSize() 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 9e397a416e..33256a2aaa 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 @@ -214,7 +214,7 @@ fun SendConfirmScreen( } @Composable -private fun AutoPayOverlay( +internal fun AutoPayOverlay( isVisible: Boolean, content: (@Composable () -> Unit)?, ) { 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 fc5f533ded..671a42ebc1 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 @@ -25,6 +25,8 @@ import com.synonym.bitkitcore.UsdtTransferStatus import kotlinx.coroutines.delay import to.bitkit.R import to.bitkit.ui.scaffold.AppAlertDialog +import to.bitkit.ui.screens.subscriptions.SubscriptionSuccess +import to.bitkit.ui.screens.wallets.send.AutoPayOverlay import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.theme.Colors import to.bitkit.viewmodels.AppViewModel @@ -37,19 +39,21 @@ fun PaykitUsdtReview( app: AppViewModel, onBack: () -> Unit, onDone: () -> Unit, - viewModel: UsdtViewModel = hiltViewModel() + viewModel: UsdtViewModel = hiltViewModel(), + autoPayContent: (@Composable () -> Unit)? = null, ) { val wallet by viewModel.wallet.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle() val settings by viewModel.settings.collectAsStateWithLifecycle() val lifecycle = LocalLifecycleOwner.current.lifecycle - DisposableEffect(state.busy, state.authenticationRequired) { - app.updatePaykitUsdtBusy(state.busy || state.authenticationRequired) + val paymentActive = state.busy || state.authenticationRequired + DisposableEffect(paymentActive) { + app.updatePaykitUsdtBusy(paymentActive) onDispose { app.updatePaykitUsdtBusy(false) } } var details by remember { mutableStateOf(false) } val back = { - if (!state.busy && !state.authenticationRequired) { + if (!paymentActive) { if (state.submitted) onDone() else onBack() } } @@ -62,9 +66,7 @@ fun PaykitUsdtReview( } } LaunchedEffect(Unit) { - if (viewModel.state.value.quote == null) { - viewModel.quote(requireNotNull(uiState.usdtRecipient), uiState.paykitAmount, UsdtDestination.ARBITRUM) - } + preparePayment(viewModel, app, uiState) } LaunchedEffect(state.submitted, lifecycle) { if (state.submitted) { @@ -85,52 +87,71 @@ fun PaykitUsdtReview( ) } val transfer = wallet.transfers.firstOrNull { it.id == state.quote?.id } - if (details && transfer != null) { + val initialPaymentConfirmed = uiState.isInitialSubscriptionPayment && + transfer?.status == UsdtTransferStatus.CONFIRMED + if (initialPaymentConfirmed && state.submitted && !state.busy) { + SubscriptionSuccess(onClose = onDone, paymentType = null, usdtAmount = transfer.amount) + } else if (details && transfer != null) { UsdtActivityDetail(transfer, settings.hideBalance, wallet.address) { details = false } } else { - Column( - Modifier.sheetHeight().background(Colors.Gray7) - ) { - if (state.authenticationRequired) { - Box(modifier = Modifier.padding(horizontal = 16.dp)) { - UsdtAuthentication( - settings.isBiometricEnabled, - viewModel::cancelAuthentication, - viewModel::authenticationVerified + Box { + Column( + Modifier.sheetHeight().background(Colors.Gray7) + ) { + if (state.authenticationRequired) { + Box(modifier = Modifier.padding(horizontal = 16.dp)) { + UsdtAuthentication( + settings.isBiometricEnabled, + viewModel::cancelAuthentication, + viewModel::authenticationVerified + ) + } + } else { + UsdtPaymentContent( + wallet, state, UsdtPage.AMOUNT, + uiState.usdtRecipient.orEmpty(), uiState.paykitAmount, UsdtDestination.ARBITRUM, + onRecipientChange = {}, + onAmountChange = {}, + onRecipientContinue = {}, + onPageChange = { back() }, + onBack = back, + onReview = { + viewModel.quote( + requireNotNull(uiState.usdtRecipient), + uiState.paykitAmount, + UsdtDestination.ARBITRUM + ) + }, + onConfirm = { + viewModel.confirm() + }, + onDone = onDone, + onDetails = { details = true }, + hideBalance = settings.hideBalance, + amountEditable = false, + isPaymentRequest = uiState.isPaymentRequest, + paymentRequestNote = uiState.paymentRequestNote, + contact = uiState.contactPaymentProfile, ) } - } else { - UsdtPaymentContent( - wallet, state, UsdtPage.AMOUNT, - uiState.usdtRecipient.orEmpty(), uiState.paykitAmount, UsdtDestination.ARBITRUM, - onRecipientChange = {}, - onAmountChange = {}, - onRecipientContinue = {}, - onPageChange = { back() }, - onBack = back, - onReview = { - viewModel.quote( - requireNotNull(uiState.usdtRecipient), - uiState.paykitAmount, - UsdtDestination.ARBITRUM - ) - }, - onConfirm = { - viewModel.confirm() - }, - onDone = onDone, - onDetails = { details = true }, - hideBalance = settings.hideBalance, - amountEditable = false, - isPaymentRequest = uiState.isPaymentRequest, - paymentRequestNote = uiState.paymentRequestNote, - contact = uiState.contactPaymentProfile, - ) } + AutoPayOverlay(isVisible = state.busy, content = autoPayContent) } } } +private suspend fun preparePayment(viewModel: UsdtViewModel, app: AppViewModel, uiState: SendUiState) { + val automaticPayment = app.takeInitialUsdtSubscriptionAuthorization() + if (automaticPayment || viewModel.state.value.quote == null) { + viewModel.quote( + requireNotNull(uiState.usdtRecipient), + uiState.paykitAmount, + UsdtDestination.ARBITRUM + ).join() + } + if (automaticPayment && viewModel.state.value.quote != null) viewModel.confirm() +} + private suspend fun reconcileSubmittedPayment(viewModel: UsdtViewModel, app: AppViewModel) { viewModel.waitForTransfer() while (viewModel.wallet.value.transfers.any { diff --git a/app/src/main/java/to/bitkit/ui/sheets/NewTransactionSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/NewTransactionSheet.kt index 2779847022..69fe232a9b 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/NewTransactionSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/NewTransactionSheet.kt @@ -217,7 +217,7 @@ private fun TransactionHeader(details: NewTransactionSheetDetails) { private val ConfettiShade = Color(0xFF8E8E93) @Composable -private fun TransactionConfetti(details: NewTransactionSheetDetails) { +internal fun TransactionConfetti(details: NewTransactionSheetDetails) { val composition by rememberLottieComposition( if (details.type == NewTransactionSheetType.ONCHAIN) { LottieCompositionSpec.RawRes(R.raw.confetti_orange) diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 0614da0c3b..de46fbcb25 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -325,11 +325,29 @@ fun SendSheet( } composableWithDefaultTransitions { val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() + val initialSubscriptionId = uiState.incomingPaymentRequestId + ?.takeIf { uiState.isInitialSubscriptionPayment } + ?.let { id -> PaykitSubscriptionId(id.paymentRequestId, id.counterparty) } + val initialSubscription = remember(initialSubscriptionId) { + initialSubscriptionId?.let(appViewModel::subscription) + } PaykitUsdtReview( uiState = uiState, app = appViewModel, - onBack = { navController.popBackStack() }, - onDone = { appViewModel.hideSheet() } + onBack = { + if (!navController.popBackStack()) { + appViewModel.hideSheet() + } + }, + onDone = { appViewModel.hideSheet() }, + autoPayContent = initialSubscription?.let { subscription -> + { + SubscriptionFirstPaymentProgress( + subscription = subscription, + contact = contacts.contactFor(subscription), + ) + } + }, ) } composableWithDefaultTransitions { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index aa102299fe..8c0e78e133 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -29,6 +29,7 @@ import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.Scanner import com.synonym.bitkitcore.SortDirection import com.synonym.bitkitcore.UsdtQuote +import com.synonym.paykit.PaymentConversion import com.synonym.paykit.PaymentRequestLifecycleState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -3560,7 +3561,24 @@ class AppViewModel @Inject constructor( } refreshPaykitRequestAmount() updateCanSwitchWallet() - navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount) + val initialPayment = payment.initialUsdtSubscriptionPayment(Clock.System.now())?.takeIf { useUsdt } + if (initialPayment != null) { + paykitReviewedAmount = initialPayment.amount + paykitPaymentTerms = initialPayment + _sendUiState.update { it.copy(requiresAmountReview = false) } + navigateToSendRoute(fromMainScanner, SendRoute.UsdtReview, SendEffect.NavigateToUsdtReview) + } else { + navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount) + } + } + + fun takeInitialUsdtSubscriptionAuthorization(): Boolean { + val state = _sendUiState.value + if (!state.initialSubscriptionPaymentAutoStartPending || !state.paykitUsesUsdt) return false + _sendUiState.update { it.copy(initialSubscriptionPaymentAutoStartPending = false) } + val payment = synchronized(contactPaymentContextLock) { activeContactPaymentContext } + ?.initialUsdtSubscriptionPayment(Clock.System.now()) ?: return false + return state.shouldAutomaticallyPay && payment == paykitPaymentTerms && payment.amount == paykitReviewedAmount } private fun prefersUsdt( @@ -8051,7 +8069,24 @@ data class ContactPaymentContext( val isInitialSubscriptionPayment: Boolean = false, val selectedTags: ImmutableList = persistentListOf(), val endpoints: List = emptyList(), -) +) { + fun initialUsdtSubscriptionPayment(at: Instant): PaykitRequestPayment? { + val request = incomingPaymentRequest ?: return null + if (!isInitialSubscriptionPayment || request.billingPeriod == null || request.isExpired(at)) return null + if (endpoints.isEmpty() || endpoints.any { it.methodId != MethodId.UsdtArbitrum }) return null + val unchangedAmount = when (request.amount.asset) { + PaykitAsset.USDT -> request.pricing.conversion == null + PaykitAsset.USD -> { + val rates = (request.pricing.conversion as? PaymentConversion.Fixed)?.rates.orEmpty() + val rate = rates.firstOrNull { it.asset == "usdt-arbitrum" } ?: rates.firstOrNull { it.asset == "usdt" } + rate?.value?.toBigDecimalOrNull()?.compareTo(BigDecimal.ONE) == 0 + } + else -> false + } + if (!unchangedAmount) return null + return runCatching { request.payment(MethodId.UsdtArbitrum, at) }.getOrNull()?.takeIf { it.isValid(at) } + } +} private data class PaymentProofPreparation( val endpointIdentifier: String, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index f492509e4b..ab5ef2edae 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -10317,6 +10317,71 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentRequestRepo).refresh(PaykitPaymentRequestRefreshMode.FULL) } + @Test + fun `initial usdt subscription authorization only accepts unchanged single method terms`() = test { + val now = Clock.System.now() + val endpoint = Endpoint(MethodId.UsdtArbitrum, "0x1111111111111111111111111111111111111111", null, null, "") + val cases = listOf( + Triple(PaykitAsset.USDT, null, true), + Triple(PaykitAsset.USD, PaymentConversion.Fixed(listOf(ConversionRate("usdt", "1.0"))), true), + Triple(PaykitAsset.USD, PaymentConversion.Fixed(listOf(ConversionRate("usdt", "2"))), false), + Triple( + PaykitAsset.USD, + PaymentConversion.Fixed(listOf(ConversionRate("usdt", "1"), ConversionRate("usdt-arbitrum", "2"))), + false, + ), + Triple(PaykitAsset.USDT, PaymentConversion.Fixed(listOf(ConversionRate("usdt", "2"))), false), + Triple(PaykitAsset.USD, PaymentConversion.PerPeriod, false), + Triple(PaykitAsset.BTC, PaymentConversion.Fixed(listOf(ConversionRate("usdt", "100000"))), false), + ) + for ((asset, conversion, eligible) in cases) { + val request = paymentRequest().copy( + amount = PaykitAmount(asset, 100u), + billingPeriod = PaykitBillingPeriod(now, now + 3600.seconds), + pricing = PaykitRequestPricing(conversion), + expiresAt = now + 60.seconds, + acceptedPaymentEndpointIdentifiers = listOf(endpoint.methodId.rawValue), + ) + val payment = ContactPaymentContext( + testPublicKey, + incomingPaymentRequest = request, + isInitialSubscriptionPayment = true, + endpoints = listOf(endpoint), + ) + assertEquals(eligible, payment.initialUsdtSubscriptionPayment(now) != null) + assertNull(payment.initialUsdtSubscriptionPayment(now + 61.seconds)) + assertNull(payment.copy(isInitialSubscriptionPayment = false).initialUsdtSubscriptionPayment(now)) + assertNull( + payment.copy(endpoints = listOf(endpoint, endpoint.copy(methodId = MethodId.P2wpkh))) + .initialUsdtSubscriptionPayment(now) + ) + } + } + + @Test + fun `eligible initial usdt subscription opens review with one use authorization`() = test { + val now = Clock.System.now() + val endpoint = Endpoint(MethodId.UsdtArbitrum, "0x1111111111111111111111111111111111111111", null, null, "") + val request = paymentRequest().copy( + amount = PaykitAmount(PaykitAsset.USD, 5u), + billingPeriod = PaykitBillingPeriod(now, now + 3600.seconds), + pricing = PaykitRequestPricing(PaymentConversion.Fixed(listOf(ConversionRate("usdt", "1")))), + acceptedPaymentEndpointIdentifiers = listOf(endpoint.methodId.rawValue), + ) + sut.openContactPayment( + endpoint.value, + testPublicKey, + incomingPaymentRequest = request, + endpoints = listOf(endpoint), + isInitialSubscriptionPayment = true, + ) + advanceUntilIdle() + assertEquals(SendRoute.UsdtReview, (sut.currentSheet.value as? Sheet.Send)?.route) + assertTrue(sut.takeInitialUsdtSubscriptionAuthorization()) + assertFalse(sut.takeInitialUsdtSubscriptionAuthorization()) + verify(paykitUsdtPayments, never()).send(any(), any(), any()) + } + @Test fun `initial subscription payment auto start is consumed once`() = test { balanceState.value = BalanceState(maxSendLightningSats = 100_000u) @@ -10661,7 +10726,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertTrue(awaitItem() is Sheet.Subscription) assertTrue(sut.acceptSubscriptionAndStartPayment(subscription).getOrThrow()) advanceUntilIdle() - assertEquals(Sheet.Send(SendRoute.Amount), awaitItem()) + assertEquals(Sheet.Send(SendRoute.UsdtReview), awaitItem()) expectNoEvents() } verify(privatePaykitRepo).beginPaymentRequestWaitingForUpdatedList(dueRequest) diff --git a/changelog.d/next/1540.fixed.md b/changelog.d/next/1540.fixed.md new file mode 100644 index 0000000000..e6879f3e3a --- /dev/null +++ b/changelog.d/next/1540.fixed.md @@ -0,0 +1 @@ +Eligible initial USDT subscription payments now continue after subscribing without a second payment swipe, while retaining payment authentication and safety checks. diff --git a/journeys/subscriptions/review-and-subscribe.xml b/journeys/subscriptions/review-and-subscribe.xml index 0dd55c7f6b..5a2c31ea6b 100644 --- a/journeys/subscriptions/review-and-subscribe.xml +++ b/journeys/subscriptions/review-and-subscribe.xml @@ -3,10 +3,11 @@ Payer side of create-and-propose.xml. The proposed subscription arrives, surfaces under PROPOSALS, and opens the Review and Subscribe sheet where the payer swipes to accept. Accepting does not return to the list on its own: when the first period is due on acceptance the first payment starts - by itself, from Lightning or on-chain savings, with no second swipe; otherwise the Subscribed + by itself, from Lightning, on-chain savings or an eligible USDT-only payment, with no second swipe; otherwise the Subscribed confirmation opens directly. Both paths end on Close. A hardware wallet still shows the send confirmation for the first payment. - Run this on the payer instance immediately after the creator sends the proposal. + Run this on the payer instance immediately after the creator sends the proposal. For the USDT + steps, configure both apps for the local USDT testing stack and use local test funds only. The handoff's "Automatically pay this subscription" toggle is unbuilt on both platforms. @@ -31,9 +32,10 @@ Only when a payment was made: verify the PAYMENTS section lists it titled "Journey Sub" with the payment date as its subtitle. With no payment yet, the section is absent. Have the creator send a second proposal for 2 USD monthly, accepting only USDT, with its first period due on acceptance; open its Review and Subscribe sheet on the payer with USDT enabled and enough USDT for 2 USDT plus the payment fee Verify the requested amount is $2, USDT is the only payment method and the swipe knob is USDT teal; swipe to subscribe - Verify the Amount screen keeps $2 locked and USDT as its only payment source; no Bitcoin amount, Savings source or editable amount is offered - Tap Continue and verify the USDT review shows 2 USDT, the creator's address and the fee; do not swipe to pay - Close the send sheet, reopen this subscription's unpaid first period from Payments, and verify it still offers the locked 2 USDT payment; cancel without sending + Verify the Review and Subscribe layout shows payment preparation without another amount-entry step or payment swipe. Complete any enabled PIN or biometric authentication and required safety warnings. Wait for Subscribed, then tap Close. + Verify the first period has only one payment in activity and is no longer offered for payment. Reopen the subscription and verify its next billing period remains scheduled. + Have the creator send another USDT-only proposal. Enable PIN for payments before accepting it. Swipe to subscribe, cancel authentication, and verify no payment was sent. Use Back to close the payment sheet. Reopen the unpaid period and explicitly authorize it; verify one payment, not two. + Repeat with a USDT-only proposal whose amount exceeds the payer's balance including fees. Swipe to subscribe and verify an insufficient-funds or fee-collection error prevents payment. Close and reopen the unpaid period; verify it remains payable and no payment was recorded. Return to the Overview tab with both the Bitcoin and USD subscriptions active. Verify Monthly Cost shows one total in the selected display currency, using Bitkit's exchange rate, aligned with Active and Created rather than separate stacked amounts. From 21fd91e7c51b2f94f30b3c137cc3e774d10e51b0 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 18 Oct 2026 19:34:46 -0600 Subject: [PATCH 2/7] Guard initial subscription method selection and cancellation --- .../screens/wallets/usdt/PaykitUsdtReview.kt | 2 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 6 +- .../screens/wallets/usdt/UsdtViewModelTest.kt | 80 +++++++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 33 ++++++++ 4 files changed, 119 insertions(+), 2 deletions(-) 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 671a42ebc1..b63ad115fa 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 @@ -140,7 +140,7 @@ fun PaykitUsdtReview( } } -private suspend fun preparePayment(viewModel: UsdtViewModel, app: AppViewModel, uiState: SendUiState) { +internal suspend fun preparePayment(viewModel: UsdtViewModel, app: AppViewModel, uiState: SendUiState) { val automaticPayment = app.takeInitialUsdtSubscriptionAuthorization() if (automaticPayment || viewModel.state.value.quote == null) { viewModel.quote( diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 8c0e78e133..40e3def85a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -8073,7 +8073,11 @@ data class ContactPaymentContext( fun initialUsdtSubscriptionPayment(at: Instant): PaykitRequestPayment? { val request = incomingPaymentRequest ?: return null if (!isInitialSubscriptionPayment || request.billingPeriod == null || request.isExpired(at)) return null - if (endpoints.isEmpty() || endpoints.any { it.methodId != MethodId.UsdtArbitrum }) return null + if (request.acceptedPaymentEndpointIdentifiers.toSet() != setOf(MethodId.UsdtArbitrum.rawValue) || + endpoints.isEmpty() || endpoints.any { it.methodId != MethodId.UsdtArbitrum } + ) { + return null + } val unchangedAmount = when (request.amount.asset) { PaykitAsset.USDT -> request.pricing.conversion == null PaykitAsset.USD -> { diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModelTest.kt index e429b4dd5c..0747f37755 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModelTest.kt @@ -8,15 +8,19 @@ import com.synonym.bitkitcore.UsdtTransfer import com.synonym.bitkitcore.usdtFormatAmount import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Test import org.mockito.Mockito import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -32,7 +36,9 @@ import to.bitkit.repositories.UsdtSelfSendError import to.bitkit.repositories.UsdtWalletState import to.bitkit.repositories.parseUsdtPaymentRequest import to.bitkit.test.BaseUnitTest +import to.bitkit.viewmodels.AppViewModel import to.bitkit.viewmodels.SanityWarning +import to.bitkit.viewmodels.SendUiState import java.io.IOException import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -41,6 +47,80 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class UsdtViewModelTest : BaseUnitTest() { + @Test + fun `initial subscription prepares before authorization and cancellation requires explicit retry`() = test { + val viewModel = createViewModel() + val app = mock() + whenever(app.takeInitialUsdtSubscriptionAuthorization()).thenReturn(true, false) + val uiState = SendUiState(usdtRecipient = quote.recipient, paykitAmount = "1") + val ready = CompletableDeferred>() + whenever(repo.quote(any(), any(), any(), any())).doSuspendableAnswer { ready.await() } + val preparation = launch { preparePayment(viewModel, app, uiState) } + advanceUntilIdle() + assertFalse(viewModel.state.value.authenticationRequired) + verify(repo, never()).send(any(), anyOrNull(), any()) + ready.complete(Result.success(quote)) + preparation.join() + advanceUntilIdle() + assertTrue(viewModel.state.value.authenticationRequired) + viewModel.cancelAuthentication() + preparePayment(viewModel, app, uiState) + advanceUntilIdle() + assertFalse(viewModel.state.value.authenticationRequired) + verify(repo, times(1)).quote(any(), any(), any(), any()) + verify(repo, never()).send(any(), anyOrNull(), any()) + + viewModel.confirm().join() + assertTrue(viewModel.state.value.authenticationRequired) + viewModel.authenticationVerified() + advanceUntilIdle() + preparePayment(viewModel, app, uiState) + viewModel.authenticationVerified() + advanceUntilIdle() + assertTrue(viewModel.state.value.submitted) + verify(repo, times(1)).send(any(), anyOrNull(), any()) + } + + @Test + fun `failed initial subscription quote cannot authorize automatically after recovery`() = test { + for (error in listOf(UsdtException.InsufficientBalance(), UsdtException.QuoteExpired())) { + val viewModel = createViewModel() + val app = mock() + whenever(app.takeInitialUsdtSubscriptionAuthorization()).thenReturn(true, false) + val uiState = SendUiState(usdtRecipient = quote.recipient, paykitAmount = "1") + whenever(repo.quote(any(), any(), any(), any())).thenReturn(Result.failure(error), Result.success(quote)) + preparePayment(viewModel, app, uiState) + advanceUntilIdle() + assertNull(viewModel.state.value.quote) + assertFalse(viewModel.state.value.authenticationRequired) + preparePayment(viewModel, app, uiState) + advanceUntilIdle() + assertEquals(quote, viewModel.state.value.quote) + assertFalse(viewModel.state.value.authenticationRequired) + verify(repo, never()).send(any(), anyOrNull(), any()) + } + } + + @Test + fun `cancelled initial subscription preparation cannot authorize when quote finishes`() = test { + val viewModel = createViewModel() + val app = mock() + whenever(app.takeInitialUsdtSubscriptionAuthorization()).thenReturn(true, false) + val uiState = SendUiState(usdtRecipient = quote.recipient, paykitAmount = "1") + val ready = CompletableDeferred>() + whenever(repo.quote(any(), any(), any(), any())).doSuspendableAnswer { ready.await() } + val preparation = launch { preparePayment(viewModel, app, uiState) } + advanceUntilIdle() + preparation.cancelAndJoin() + ready.complete(Result.success(quote)) + advanceUntilIdle() + preparePayment(viewModel, app, uiState) + advanceUntilIdle() + assertEquals(quote, viewModel.state.value.quote) + assertFalse(viewModel.state.value.authenticationRequired) + verify(repo, never()).send(any(), anyOrNull(), any()) + } + @Test fun `settled payment rejects replacement quotes until explicitly edited`() = test { val viewModel = createViewModel(walletState = UsdtWalletState(address = "sender")) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index ab5ef2edae..f7415f5576 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -10351,6 +10351,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(eligible, payment.initialUsdtSubscriptionPayment(now) != null) assertNull(payment.initialUsdtSubscriptionPayment(now + 61.seconds)) assertNull(payment.copy(isInitialSubscriptionPayment = false).initialUsdtSubscriptionPayment(now)) + assertNull( + payment.copy( + incomingPaymentRequest = request.copy( + acceptedPaymentEndpointIdentifiers = listOf( + endpoint.methodId.rawValue, + MethodId.P2wpkh.rawValue, + ) + ) + ).initialUsdtSubscriptionPayment(now) + ) assertNull( payment.copy(endpoints = listOf(endpoint, endpoint.copy(methodId = MethodId.P2wpkh))) .initialUsdtSubscriptionPayment(now) @@ -10382,6 +10392,29 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitUsdtPayments, never()).send(any(), any(), any()) } + @Test + fun `mixed accepted methods still require selection when only usdt resolves`() = test { + val now = Clock.System.now() + val endpoint = Endpoint(MethodId.UsdtArbitrum, "0x1111111111111111111111111111111111111111", null, null, "") + val request = paymentRequest().copy( + amount = PaykitAmount(PaykitAsset.USD, 5u), + billingPeriod = PaykitBillingPeriod(now, now + 3600.seconds), + pricing = PaykitRequestPricing(PaymentConversion.Fixed(listOf(ConversionRate("usdt", "1")))), + acceptedPaymentEndpointIdentifiers = listOf(endpoint.methodId.rawValue, MethodId.P2wpkh.rawValue), + ) + sut.openContactPayment( + endpoint.value, + testPublicKey, + incomingPaymentRequest = request, + endpoints = listOf(endpoint), + isInitialSubscriptionPayment = true, + ) + advanceUntilIdle() + assertEquals(SendRoute.Amount, (sut.currentSheet.value as? Sheet.Send)?.route) + assertFalse(sut.takeInitialUsdtSubscriptionAuthorization()) + verify(paykitUsdtPayments, never()).send(any(), any(), any()) + } + @Test fun `initial subscription payment auto start is consumed once`() = test { balanceState.value = BalanceState(maxSendLightningSats = 100_000u) From 1711f13c864fc82a0ac5c8a53315a149db00569e Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 19 Oct 2026 07:51:11 -0600 Subject: [PATCH 3/7] fix: keep manual subscription payment review --- .../screens/wallets/usdt/PaykitUsdtReview.kt | 22 +++++++++---- .../screens/wallets/usdt/UsdtViewModelTest.kt | 32 +++++++++++++++++-- .../subscriptions/review-and-subscribe.xml | 4 +-- 3 files changed, 47 insertions(+), 11 deletions(-) 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 b63ad115fa..af2ccc203c 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 @@ -52,6 +52,7 @@ fun PaykitUsdtReview( onDispose { app.updatePaykitUsdtBusy(false) } } var details by remember { mutableStateOf(false) } + var automaticPayment by remember { mutableStateOf(false) } val back = { if (!paymentActive) { if (state.submitted) onDone() else onBack() @@ -66,13 +67,11 @@ fun PaykitUsdtReview( } } LaunchedEffect(Unit) { - preparePayment(viewModel, app, uiState) + preparePayment(viewModel, app, uiState) { automaticPayment = it } } LaunchedEffect(state.submitted, lifecycle) { - if (state.submitted) { - lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - reconcileSubmittedPayment(viewModel, app) - } + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + reconcileSubmittedPayment(viewModel, app) } } BackHandler { if (state.authenticationRequired) viewModel.cancelAuthentication() else back() } @@ -116,6 +115,7 @@ fun PaykitUsdtReview( onPageChange = { back() }, onBack = back, onReview = { + automaticPayment = false viewModel.quote( requireNotNull(uiState.usdtRecipient), uiState.paykitAmount, @@ -123,6 +123,7 @@ fun PaykitUsdtReview( ) }, onConfirm = { + automaticPayment = false viewModel.confirm() }, onDone = onDone, @@ -135,13 +136,19 @@ fun PaykitUsdtReview( ) } } - AutoPayOverlay(isVisible = state.busy, content = autoPayContent) + AutoPayOverlay(isVisible = automaticPayment && state.busy, content = autoPayContent) } } } -internal suspend fun preparePayment(viewModel: UsdtViewModel, app: AppViewModel, uiState: SendUiState) { +internal suspend fun preparePayment( + viewModel: UsdtViewModel, + app: AppViewModel, + uiState: SendUiState, + onAutomaticPayment: (Boolean) -> Unit = {}, +) { val automaticPayment = app.takeInitialUsdtSubscriptionAuthorization() + onAutomaticPayment(automaticPayment) if (automaticPayment || viewModel.state.value.quote == null) { viewModel.quote( requireNotNull(uiState.usdtRecipient), @@ -153,6 +160,7 @@ internal suspend fun preparePayment(viewModel: UsdtViewModel, app: AppViewModel, } private suspend fun reconcileSubmittedPayment(viewModel: UsdtViewModel, app: AppViewModel) { + if (!viewModel.state.value.submitted) return viewModel.waitForTransfer() while (viewModel.wallet.value.transfers.any { it.id == viewModel.state.value.quote?.id && it.status == UsdtTransferStatus.PENDING diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModelTest.kt index 0747f37755..a0ab82a223 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModelTest.kt @@ -55,8 +55,10 @@ class UsdtViewModelTest : BaseUnitTest() { val uiState = SendUiState(usdtRecipient = quote.recipient, paykitAmount = "1") val ready = CompletableDeferred>() whenever(repo.quote(any(), any(), any(), any())).doSuspendableAnswer { ready.await() } - val preparation = launch { preparePayment(viewModel, app, uiState) } + var automaticPayment = false + val preparation = launch { preparePayment(viewModel, app, uiState) { automaticPayment = it } } advanceUntilIdle() + assertTrue(automaticPayment) assertFalse(viewModel.state.value.authenticationRequired) verify(repo, never()).send(any(), anyOrNull(), any()) ready.complete(Result.success(quote)) @@ -64,7 +66,8 @@ class UsdtViewModelTest : BaseUnitTest() { advanceUntilIdle() assertTrue(viewModel.state.value.authenticationRequired) viewModel.cancelAuthentication() - preparePayment(viewModel, app, uiState) + preparePayment(viewModel, app, uiState) { automaticPayment = it } + assertFalse(automaticPayment) advanceUntilIdle() assertFalse(viewModel.state.value.authenticationRequired) verify(repo, times(1)).quote(any(), any(), any(), any()) @@ -81,6 +84,31 @@ class UsdtViewModelTest : BaseUnitTest() { verify(repo, times(1)).send(any(), anyOrNull(), any()) } + @Test + fun `manual initial subscription preparation never shows automatic progress or authorizes`() = test { + val viewModel = createViewModel() + val app = mock() + whenever(app.takeInitialUsdtSubscriptionAuthorization()).thenReturn(false) + val uiState = SendUiState( + usdtRecipient = quote.recipient, + paykitAmount = "1", + isInitialSubscriptionPayment = true, + ) + val ready = CompletableDeferred>() + whenever(repo.quote(any(), any(), any(), any())).doSuspendableAnswer { ready.await() } + var automaticPayment = true + val preparation = launch { preparePayment(viewModel, app, uiState) { automaticPayment = it } } + advanceUntilIdle() + assertTrue(viewModel.state.value.busy) + assertFalse(automaticPayment) + ready.complete(Result.success(quote)) + preparation.join() + advanceUntilIdle() + assertEquals(quote, viewModel.state.value.quote) + assertFalse(viewModel.state.value.authenticationRequired) + verify(repo, never()).send(any(), anyOrNull(), any()) + } + @Test fun `failed initial subscription quote cannot authorize automatically after recovery`() = test { for (error in listOf(UsdtException.InsufficientBalance(), UsdtException.QuoteExpired())) { diff --git a/journeys/subscriptions/review-and-subscribe.xml b/journeys/subscriptions/review-and-subscribe.xml index 5a2c31ea6b..1e8e1849ab 100644 --- a/journeys/subscriptions/review-and-subscribe.xml +++ b/journeys/subscriptions/review-and-subscribe.xml @@ -32,9 +32,9 @@ Only when a payment was made: verify the PAYMENTS section lists it titled "Journey Sub" with the payment date as its subtitle. With no payment yet, the section is absent. Have the creator send a second proposal for 2 USD monthly, accepting only USDT, with its first period due on acceptance; open its Review and Subscribe sheet on the payer with USDT enabled and enough USDT for 2 USDT plus the payment fee Verify the requested amount is $2, USDT is the only payment method and the swipe knob is USDT teal; swipe to subscribe - Verify the Review and Subscribe layout shows payment preparation without another amount-entry step or payment swipe. Complete any enabled PIN or biometric authentication and required safety warnings. Wait for Subscribed, then tap Close. + Verify the Review and Subscribe layout shows payment preparation without another amount-entry step or payment swipe. Complete any enabled PIN or biometric authentication and required safety warnings. Wait for Subscribed and verify no Back button or swipe-back returns to the paid period; then tap Close. Verify the first period has only one payment in activity and is no longer offered for payment. Reopen the subscription and verify its next billing period remains scheduled. - Have the creator send another USDT-only proposal. Enable PIN for payments before accepting it. Swipe to subscribe, cancel authentication, and verify no payment was sent. Use Back to close the payment sheet. Reopen the unpaid period and explicitly authorize it; verify one payment, not two. + Have the creator send another USDT-only proposal. Enable PIN for payments before accepting it. Swipe to subscribe, cancel authentication, and verify no payment was sent. Use Back to close the payment sheet. Reopen the unpaid period and verify it waits at payment review without a completed Subscribe swipe or automatic-payment progress. Explicitly authorize it and verify one payment, not two. Repeat with a USDT-only proposal whose amount exceeds the payer's balance including fees. Swipe to subscribe and verify an insufficient-funds or fee-collection error prevents payment. Close and reopen the unpaid period; verify it remains payable and no payment was recorded. Return to the Overview tab with both the Bitcoin and USD subscriptions active. Verify Monthly Cost shows one total in the selected display currency, using Bitkit's exchange rate, aligned with Active and Created rather than separate stacked amounts. From f6f2c6313c69d7401a51053d82dfe50ec8e71c57 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 19 Oct 2026 08:42:24 -0600 Subject: [PATCH 4/7] fix: keep locked payment amounts on review --- .../wallets/usdt/UsdtPaymentContentTest.kt | 39 ++++++++++++++++++- .../screens/wallets/usdt/UsdtSendContent.kt | 2 +- .../subscriptions/review-and-subscribe.xml | 2 +- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/usdt/UsdtPaymentContentTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/usdt/UsdtPaymentContentTest.kt index 5118ac38af..91e3539fc1 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/usdt/UsdtPaymentContentTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/usdt/UsdtPaymentContentTest.kt @@ -34,6 +34,7 @@ import to.bitkit.repositories.UsdtWalletState import to.bitkit.test.annotations.ComposeUi import to.bitkit.ui.theme.AppThemeSurface import java.util.Locale +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -69,6 +70,36 @@ class UsdtPaymentContentTest { Locale.setDefault(originalLocale) } + @Test + fun lockedReviewAmountKeepsReviewOpenWhileBackAndEditableAmountsStillNavigate() { + val editable = mutableStateOf(false) + var pageChanges = 0 + var backPresses = 0 + val payment = quote.copy(destination = UsdtDestination.ARBITRUM, bridgeProvider = null) + composeTestRule.setContent { + Content( + UsdtSendState(quote = payment), UsdtPage.AMOUNT, + amountEditable = editable.value, + onPageChange = { pageChanges++ }, + onBack = { backPresses++ }, + ) + } + composeTestRule.onNodeWithText("3.00").performClick() + composeTestRule.onNodeWithTag("UsdtConfirm").assertIsDisplayed() + composeTestRule.runOnIdle { + assertEquals(0, pageChanges) + assertEquals(0, backPresses) + } + composeTestRule.onNodeWithTag("NavigationBack").performClick() + composeTestRule.runOnIdle { + assertEquals(1, backPresses) + editable.value = true + } + SystemClock.sleep(600) + composeTestRule.onNodeWithText("3.00").performClick() + composeTestRule.runOnIdle { assertEquals(1, pageChanges) } + } + @Test fun bridgeAmountsAndNetworksAreInExpandedDetails() { composeTestRule.setContent { Content(UsdtSendState(quote = quote), UsdtPage.AMOUNT) } @@ -223,6 +254,9 @@ class UsdtPaymentContentTest { state: UsdtSendState, page: UsdtPage, wallet: UsdtWalletState = UsdtWalletState(), + amountEditable: Boolean = true, + onPageChange: (UsdtPage) -> Unit = {}, + onBack: () -> Unit = {}, onDetails: () -> Unit = {}, ) { AppThemeSurface { @@ -238,12 +272,13 @@ class UsdtPaymentContentTest { onRecipientChange = {}, onAmountChange = {}, onRecipientContinue = {}, - onPageChange = {}, - onBack = {}, + onPageChange = onPageChange, + onBack = onBack, onReview = {}, onConfirm = {}, onDone = {}, onDetails = onDetails, + amountEditable = amountEditable, ) } } 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 2f9b3f6a2e..66ec5b42e1 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 @@ -211,7 +211,7 @@ internal fun UsdtPaymentContent( contact, onConfirm, completed, - onEditAmount = { onPageChange(UsdtPage.AMOUNT) }, + onEditAmount = { if (amountEditable) onPageChange(UsdtPage.AMOUNT) }, ) page == UsdtPage.AMOUNT -> UsdtAmountContent( amount, diff --git a/journeys/subscriptions/review-and-subscribe.xml b/journeys/subscriptions/review-and-subscribe.xml index 1e8e1849ab..d3493359ae 100644 --- a/journeys/subscriptions/review-and-subscribe.xml +++ b/journeys/subscriptions/review-and-subscribe.xml @@ -34,7 +34,7 @@ Verify the requested amount is $2, USDT is the only payment method and the swipe knob is USDT teal; swipe to subscribe Verify the Review and Subscribe layout shows payment preparation without another amount-entry step or payment swipe. Complete any enabled PIN or biometric authentication and required safety warnings. Wait for Subscribed and verify no Back button or swipe-back returns to the paid period; then tap Close. Verify the first period has only one payment in activity and is no longer offered for payment. Reopen the subscription and verify its next billing period remains scheduled. - Have the creator send another USDT-only proposal. Enable PIN for payments before accepting it. Swipe to subscribe, cancel authentication, and verify no payment was sent. Use Back to close the payment sheet. Reopen the unpaid period and verify it waits at payment review without a completed Subscribe swipe or automatic-payment progress. Explicitly authorize it and verify one payment, not two. + Have the creator send another USDT-only proposal. Enable PIN for payments before accepting it. Swipe to subscribe, cancel authentication, and verify no payment was sent. Tap the locked amount and verify the payment review stays open. Use Back to close the payment sheet. Reopen the unpaid period and verify it waits at payment review without a completed Subscribe swipe or automatic-payment progress. Explicitly authorize it and verify one payment, not two. Repeat with a USDT-only proposal whose amount exceeds the payer's balance including fees. Swipe to subscribe and verify an insufficient-funds or fee-collection error prevents payment. Close and reopen the unpaid period; verify it remains payable and no payment was recorded. Return to the Overview tab with both the Bitcoin and USD subscriptions active. Verify Monthly Cost shows one total in the selected display currency, using Bitkit's exchange rate, aligned with Active and Created rather than separate stacked amounts. From d5ae1af371699e11fa58b1f9658cec18c395fcf5 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 19 Oct 2026 15:48:26 -0600 Subject: [PATCH 5/7] fix: show payable subscription period status --- .../paymentrequests/PaymentRequestsScreen.kt | 37 +++++++++++------- .../PaymentRequestPresentationTest.kt | 38 +++++++++++++++++++ changelog.d/next/1540.fixed.md | 2 +- .../subscriptions/review-and-subscribe.xml | 2 +- 4 files changed, 63 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt index ab0b13b356..38288ffc74 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt @@ -497,33 +497,42 @@ internal fun paymentRequestStatus( ?: stringResource(R.string.wallet__payment_request_pending) } - return paymentRequestLifecycleStatus(request, isPending) + return stringResource(paymentRequestLifecycleStatusRes(request, isPending)) } -@Composable -private fun paymentRequestLifecycleStatus(request: PaykitPaymentRequest, isPending: Boolean): String { - if (request.lifecycleState == PaymentRequestLifecycleState.PROPOSED && request.isExpired(Clock.System.now())) { - return stringResource(R.string.wallet__payment_request_status_expired) +@StringRes +internal fun paymentRequestLifecycleStatusRes(request: PaykitPaymentRequest, isPending: Boolean): Int { + val canExpire = request.lifecycleState == PaymentRequestLifecycleState.PROPOSED || + request.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING + if (canExpire && request.isExpired(Clock.System.now())) { + return R.string.wallet__payment_request_status_expired } return when (request.lifecycleState) { - PaymentRequestLifecycleState.PROPOSED -> stringResource(proposedPaymentRequestStatusRes(request, isPending)) + PaymentRequestLifecycleState.PROPOSED -> proposedPaymentRequestStatusRes(request, isPending) PaymentRequestLifecycleState.PROPOSAL_EXPIRED -> - stringResource(R.string.wallet__payment_request_status_expired) + R.string.wallet__payment_request_status_expired PaymentRequestLifecycleState.ACCEPTED -> - stringResource(R.string.wallet__payment_request_pending) + R.string.wallet__payment_request_pending PaymentRequestLifecycleState.REJECTED -> - stringResource(R.string.wallet__payment_request_status_rejected) + R.string.wallet__payment_request_status_rejected PaymentRequestLifecycleState.CANCELED -> - stringResource(R.string.wallet__payment_request_status_canceled) + R.string.wallet__payment_request_status_canceled PaymentRequestLifecycleState.PROOF_SUBMITTED -> - stringResource(R.string.wallet__payment_request_status_proof_submitted) + R.string.wallet__payment_request_status_proof_submitted PaymentRequestLifecycleState.RECOVERY_REQUIRED -> - stringResource(R.string.wallet__payment_request_status_action_required) + R.string.wallet__payment_request_status_action_required + PaymentRequestLifecycleState.ACTIVE_RECURRING -> + if (request.billingPeriod != null && + request.direction == PaykitPaymentRequestDirection.Incoming && isPending + ) { + R.string.wallet__payment_request_waiting + } else { + R.string.wallet__payment_request_status_unavailable + } PaymentRequestLifecycleState.INVALID_CONFLICT, - PaymentRequestLifecycleState.ACTIVE_RECURRING, PaymentRequestLifecycleState.UNKNOWN, - -> stringResource(R.string.wallet__payment_request_status_unavailable) + -> R.string.wallet__payment_request_status_unavailable } } diff --git a/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt b/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt index 865966a391..4f87e2c084 100644 --- a/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalTime::class) + package to.bitkit.ui.screens.paymentrequests import com.synonym.paykit.PaymentRequestLifecycleState @@ -5,12 +7,15 @@ import org.junit.Test import to.bitkit.R import to.bitkit.models.PaykitAmount import to.bitkit.models.PaykitAsset +import to.bitkit.repositories.PaykitBillingPeriod import to.bitkit.repositories.PaykitPaymentProofKind import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitPaymentRequestDirection import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +import kotlin.time.ExperimentalTime +import kotlin.time.Instant class PaymentRequestPresentationTest { @Test @@ -67,6 +72,39 @@ class PaymentRequestPresentationTest { ) } + @Test + fun `unpaid subscription period shows waiting only while payable`() { + val request = paymentRequest(lifecycleState = PaymentRequestLifecycleState.ACTIVE_RECURRING).copy( + amount = PaykitAmount(PaykitAsset.USDT, 2_000_000uL), + acceptedPaymentEndpointIdentifiers = listOf("usdt-arbitrum"), + billingPeriod = PaykitBillingPeriod( + startsAt = Instant.parse("2027-01-01T00:00:00Z"), + endsAt = Instant.parse("2027-02-01T00:00:00Z"), + ), + ) + + assertEquals( + R.string.wallet__payment_request_status_expired, + paymentRequestLifecycleStatusRes(request.copy(paymentDeadlineAt = Instant.DISTANT_PAST), true), + ) + assertEquals(R.string.wallet__payment_request_waiting, paymentRequestLifecycleStatusRes(request, true)) + assertEquals( + R.string.wallet__payment_request_status_unavailable, + paymentRequestLifecycleStatusRes(request, false), + ) + assertEquals( + R.string.wallet__payment_request_status_unavailable, + paymentRequestLifecycleStatusRes(request.copy(billingPeriod = null), true), + ) + assertEquals( + R.string.wallet__payment_request_status_proof_submitted, + paymentRequestLifecycleStatusRes( + request.copy(lifecycleState = PaymentRequestLifecycleState.PROOF_SUBMITTED), + false, + ), + ) + } + private fun paymentRequest( lifecycleState: PaymentRequestLifecycleState, direction: PaykitPaymentRequestDirection = PaykitPaymentRequestDirection.Incoming, diff --git a/changelog.d/next/1540.fixed.md b/changelog.d/next/1540.fixed.md index e6879f3e3a..c37bfc22da 100644 --- a/changelog.d/next/1540.fixed.md +++ b/changelog.d/next/1540.fixed.md @@ -1 +1 @@ -Eligible initial USDT subscription payments now continue after subscribing without a second payment swipe, while retaining payment authentication and safety checks. +Eligible initial USDT subscription payments continue after subscribing without a second swipe while retaining authentication and safety checks, and unpaid periods show when they can be retried. diff --git a/journeys/subscriptions/review-and-subscribe.xml b/journeys/subscriptions/review-and-subscribe.xml index d3493359ae..e840086a76 100644 --- a/journeys/subscriptions/review-and-subscribe.xml +++ b/journeys/subscriptions/review-and-subscribe.xml @@ -34,7 +34,7 @@ Verify the requested amount is $2, USDT is the only payment method and the swipe knob is USDT teal; swipe to subscribe Verify the Review and Subscribe layout shows payment preparation without another amount-entry step or payment swipe. Complete any enabled PIN or biometric authentication and required safety warnings. Wait for Subscribed and verify no Back button or swipe-back returns to the paid period; then tap Close. Verify the first period has only one payment in activity and is no longer offered for payment. Reopen the subscription and verify its next billing period remains scheduled. - Have the creator send another USDT-only proposal. Enable PIN for payments before accepting it. Swipe to subscribe, cancel authentication, and verify no payment was sent. Tap the locked amount and verify the payment review stays open. Use Back to close the payment sheet. Reopen the unpaid period and verify it waits at payment review without a completed Subscribe swipe or automatic-payment progress. Explicitly authorize it and verify one payment, not two. + Have the creator send another USDT-only proposal. Enable PIN for payments before accepting it. Swipe to subscribe, cancel authentication, and verify no payment was sent. Tap the locked amount and verify the payment review stays open. Use Back to close the payment sheet. Reopen the unpaid period from Payments and verify its detail shows Waiting for payment. Tap Pay, verify the fixed Amount screen, then tap Continue to reach payment review. Verify no completed Subscribe swipe or automatic-payment progress appears. Explicitly authorize payment and verify one payment, not two. Repeat with a USDT-only proposal whose amount exceeds the payer's balance including fees. Swipe to subscribe and verify an insufficient-funds or fee-collection error prevents payment. Close and reopen the unpaid period; verify it remains payable and no payment was recorded. Return to the Overview tab with both the Bitcoin and USD subscriptions active. Verify Monthly Cost shows one total in the selected display currency, using Bitkit's exchange rate, aligned with Active and Created rather than separate stacked amounts. From 1f55d0317d1074fe57449710cd4c3e7856cf0cb8 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 19 Oct 2026 21:39:12 -0600 Subject: [PATCH 6/7] fix: retain expired subscription periods --- .../repositories/PaykitPaymentRequestRepo.kt | 50 +++++++++----- ...aykitPaymentRequestRepoSubscriptionTest.kt | 67 ++++++++++++++++++- changelog.d/next/1540.fixed.md | 2 +- .../subscriptions/review-and-subscribe.xml | 1 + 4 files changed, 101 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 430448a937..1e62b60e02 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -1320,23 +1320,16 @@ class PaykitPaymentRequestRepo @Inject constructor( .flatten() .filter { it.lifecycleState != PaymentRequestLifecycleState.PROOF_SUBMITTED && + !it.isExpired(now) && it.id !in locallyCompletedRequestIds && it.id !in locallyInFlightRequestIds && it.id !in updatedDismissedPaymentIds } val recurringHistory = allSubscriptions.filter { it.isPayer }.flatMap { subscription -> - updatedSubscriptionAcceptedAt[subscription.id] + val requests = updatedSubscriptionAcceptedAt[subscription.id] ?.let { subscription.requestsThrough(subscriptionNow, it) } .orEmpty() - }.mapNotNull { request -> - when { - request.lifecycleState == PaymentRequestLifecycleState.PROOF_SUBMITTED -> request - request.id in locallyCompletedRequestIds -> request.copy( - lifecycleState = PaymentRequestLifecycleState.PROOF_SUBMITTED, - paymentProofKind = locallyCompletedProofKinds[request.id], - ) - else -> null - } + subscriptionHistory(subscription, requests, now, locallyCompletedProofKinds) } val oneTimeIncoming = availableRecords.mapNotNull { record -> when (val result = record.parseIncomingPaykitPaymentRequest(now)) { @@ -1410,6 +1403,24 @@ class PaykitPaymentRequestRepo @Inject constructor( return subscription.requestsThrough(now, acceptedAt) } + private fun subscriptionHistory( + subscription: PaykitSubscription, + requests: List, + now: Instant, + completedProofKinds: Map = emptyMap(), + ): List = requests.mapNotNull { request -> + when { + request.lifecycleState == PaymentRequestLifecycleState.PROOF_SUBMITTED -> request + request.id in completedProofKinds -> request.copy( + lifecycleState = PaymentRequestLifecycleState.PROOF_SUBMITTED, + paymentProofKind = completedProofKinds[request.id], + ) + subscription.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING && + request.isExpired(now) -> request + else -> null + } + } + private fun isCurrentState(generation: Long, expectedIdentity: String?): Boolean = stateGeneration.get() == generation && PubkyPublicKeyFormat.matches(activeIdentity, expectedIdentity) @@ -1653,23 +1664,24 @@ class PaykitPaymentRequestRepo @Inject constructor( } val recurringRequests = requestsThroughAcceptance(subscription, now) + val paymentNow = clock.now() val unpaidRequests = if ( subscription.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING ) { - recurringRequests.filter { it.lifecycleState != PaymentRequestLifecycleState.PROOF_SUBMITTED } + recurringRequests.filter { + it.lifecycleState != PaymentRequestLifecycleState.PROOF_SUBMITTED && !it.isExpired(paymentNow) + } } else { emptyList() } - val paidRequests = recurringRequests.filter { - it.lifecycleState == PaymentRequestLifecycleState.PROOF_SUBMITTED - } + val historyRequests = subscriptionHistory(subscription, recurringRequests, paymentNow) val existingPending = _pendingRequests.value.filterNot { it.belongsTo(subscription) } _pendingRequests.update { (unpaidRequests + existingPending).sortedBy { it.createdAt } } val existingHistory = _paymentRequestHistory.value.filterNot { it.belongsTo(subscription) } _paymentRequestHistory.update { - (paidRequests + existingHistory).sortedByDescending { it.createdAt } + (historyRequests + existingHistory).sortedByDescending { it.createdAt } } prunePresentedSubscriptionProposalIds(_subscriptions.value) subscriptionNotificationScheduler.synchronize( @@ -1737,8 +1749,14 @@ class PaykitPaymentRequestRepo @Inject constructor( private suspend fun discardExpiredRequestsLocked() { val now = clock.now() val subscriptionNow = subscriptionClock.now() + val expiredPeriods = _pendingRequests.value.filter { it.billingPeriod != null && it.isExpired(now) } + _paymentRequestHistory.update { requests -> + (requests + expiredPeriods) + .distinctBy { it.id } + .withExpiredLifecycle(now) + .sortedByDescending { it.createdAt } + } _pendingRequests.update { requests -> requests.filterNot { it.isExpired(now) } } - _paymentRequestHistory.update { requests -> requests.withExpiredLifecycle(now) } _subscriptions.update { subscriptions -> subscriptions.map { it.withExpiredLifecycle(subscriptionNow) } } prunePresentedRequestIds(_pendingRequests.value) prunePresentedSubscriptionProposalIds(_subscriptions.value) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index ae94869244..a8225b6b06 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -1173,7 +1173,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat sut.refresh().getOrThrow() - assertTrue(sut.pendingRequests.value.all { it.isPaymentDeadlineExpired(clock.now()) }) + assertTrue(sut.pendingRequests.value.isEmpty()) val unpaid = requireNotNull(sut.subscriptions.value.single { it.isPayer }.paymentDueOnAcceptance(clock.now())) assertTrue(unpaid.isPaymentDeadlineExpired(clock.now())) assertTrue(!unpaid.payment(MethodId.Bolt11, clock.now()).isValid(clock.now())) @@ -1182,10 +1182,73 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat assertEquals(1, subscription.paidPeriods.size) assertTrue(subscription.canCancel(clock.now())) } - val paid = sut.paymentRequestHistory.value.single() + assertEquals(2, sut.paymentRequestHistory.value.size) + assertTrue(sut.paymentRequestHistory.value.any { it.id == unpaid.id && it.isExpired(clock.now()) }) + val paid = sut.paymentRequestHistory.value.single { + it.lifecycleState == PaymentRequestLifecycleState.PROOF_SUBMITTED + } assertEquals(PaymentRequestLifecycleState.PROOF_SUBMITTED, paid.lifecycleState) assertEquals(PaykitPaymentProofKind.Lightning, paid.paymentProofKind) assertEquals(1, sut.subscriptions.value.single { it.isCreatedByUser }.receivedPaymentRequests().size) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( + listOf( + paymentRequestRecord( + id = PaymentRequestLocalRole.PAYER.name, + state = PaymentRequestLifecycleState.CANCELED, + paymentDeadline = PaymentDeadline.PeriodStart(3600uL), + paymentProofs = listOf(proof), + ), + ), + ) + sut.refresh().getOrThrow() + assertTrue(sut.pendingRequests.value.isEmpty()) + assertEquals(listOf(paid.id), sut.paymentRequestHistory.value.map { it.id }) + } + + @Test + fun `expired recurring period stays in history across refresh and restored state`() = test { + val start = clock.now() + val nextPeriod = Instant.parse("2027-02-15T08:00:00Z") + val subscriptionId = PaykitSubscriptionId(PAYMENT_REQUEST_ID, COUNTERPARTY) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( + listOf( + paymentRequestRecord( + state = PaymentRequestLifecycleState.ACTIVE_RECURRING, + recurrence = PaymentRequestRecurrence(1u, "month", start.toString(), start.toString(), null), + paymentDeadline = PaymentDeadline.PeriodStart(60uL), + ), + ), + ) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + advanceTimeBy(60_000) + runCurrent() + assertEquals(listOf(request), sut.pendingRequests.value) + advanceTimeBy(1) + runCurrent() + assertTrue(sut.pendingRequests.value.isEmpty()) + assertEquals(listOf(request), sut.paymentRequestHistory.value) + assertEquals(PaykitPaymentRequestError.RequestExpired, sut.ensurePaymentAllowed(request).exceptionOrNull()) + + sut.refresh().getOrThrow() + assertTrue(sut.pendingRequests.value.isEmpty()) + assertEquals(listOf(request), sut.paymentRequestHistory.value) + sut.clear() + assertTrue(sut.paymentRequestHistory.value.isEmpty()) + whenever(presentationStore.loadSubscriptionState(LOCAL_IDENTITY)).thenReturn( + PaykitSubscriptionPresentationState(acceptedAt = mapOf(subscriptionId to start)), + ) + sut.activate(LOCAL_IDENTITY) + sut.refresh().getOrThrow() + assertTrue(sut.pendingRequests.value.isEmpty()) + assertEquals(listOf(request), sut.paymentRequestHistory.value) + advanceTimeBy((nextPeriod - clock.now()).inWholeMilliseconds) + sut.refresh().getOrThrow() + val next = sut.pendingRequests.value.single() + assertTrue(next.id != request.id) + assertEquals(nextPeriod, next.billingPeriod?.startsAt) + assertFalse(next.isExpired(clock.now())) + assertEquals(listOf(request), sut.paymentRequestHistory.value) } @Test diff --git a/changelog.d/next/1540.fixed.md b/changelog.d/next/1540.fixed.md index c37bfc22da..402f44359b 100644 --- a/changelog.d/next/1540.fixed.md +++ b/changelog.d/next/1540.fixed.md @@ -1 +1 @@ -Eligible initial USDT subscription payments continue after subscribing without a second swipe while retaining authentication and safety checks, and unpaid periods show when they can be retried. +Eligible initial USDT subscription payments continue after subscribing without a second swipe while retaining authentication and safety checks. Unpaid periods show when they can be retried, and expired periods remain visible in payment history. diff --git a/journeys/subscriptions/review-and-subscribe.xml b/journeys/subscriptions/review-and-subscribe.xml index e840086a76..8d40366bb5 100644 --- a/journeys/subscriptions/review-and-subscribe.xml +++ b/journeys/subscriptions/review-and-subscribe.xml @@ -36,6 +36,7 @@ Verify the first period has only one payment in activity and is no longer offered for payment. Reopen the subscription and verify its next billing period remains scheduled. Have the creator send another USDT-only proposal. Enable PIN for payments before accepting it. Swipe to subscribe, cancel authentication, and verify no payment was sent. Tap the locked amount and verify the payment review stays open. Use Back to close the payment sheet. Reopen the unpaid period from Payments and verify its detail shows Waiting for payment. Tap Pay, verify the fixed Amount screen, then tap Continue to reach payment review. Verify no completed Subscribe swipe or automatic-payment progress appears. Explicitly authorize payment and verify one payment, not two. Repeat with a USDT-only proposal whose amount exceeds the payer's balance including fees. Swipe to subscribe and verify an insufficient-funds or fee-collection error prevents payment. Close and reopen the unpaid period; verify it remains payable and no payment was recorded. + With an unpaid subscription period whose signed payment deadline is shortly ahead, open its detail and wait past the deadline. Verify it changes from Waiting for payment to Expired with no Pay action, and its row remains in Payments. Refresh and relaunch, then verify the same expired period remains visible once and cannot be paid. When the next billing period starts, verify only the new period offers Pay and the old period remains Expired. Return to the Overview tab with both the Bitcoin and USD subscriptions active. Verify Monthly Cost shows one total in the selected display currency, using Bitkit's exchange rate, aligned with Active and Created rather than separate stacked amounts. From 19e3a31127bb11420480055ec071221cc6b8fe79 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 20 Oct 2026 04:33:15 -0600 Subject: [PATCH 7/7] fix: show only paid periods in subscription details --- .../subscriptions/SubscriptionsScreen.kt | 17 ++++++++++++----- .../PaykitPaymentRequestRepoSubscriptionTest.kt | 14 ++++++++++++-- changelog.d/next/1540.fixed.md | 2 +- journeys/subscriptions/review-and-subscribe.xml | 2 +- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt index de5436d3e5..503eea6457 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt @@ -74,6 +74,7 @@ import to.bitkit.models.PaykitExchangeRate import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.repositories.MethodId +import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PaykitRecurrenceUnit @@ -107,6 +108,7 @@ import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.scaffold.rememberChromeHazeStyle import to.bitkit.ui.screens.paymentrequests.PaymentRequestCard import to.bitkit.ui.screens.paymentrequests.PaymentRequestsScreen +import to.bitkit.ui.screens.paymentrequests.hasPaymentEvidence import to.bitkit.ui.screens.paymentrequests.paymentRequestDate import to.bitkit.ui.screens.wallets.activity.components.CustomTabRowWithSpacing import to.bitkit.ui.screens.wallets.activity.components.TabItem @@ -303,6 +305,15 @@ internal fun SubscriptionsContent( } } +internal fun subscriptionPayments( + subscription: PaykitSubscription, + history: List, +): List = if (subscription.isCreatedByUser) { + subscription.receivedPaymentRequests() +} else { + history.filter { it.belongsTo(subscription) && it.hasPaymentEvidence } +} + internal data class SubscriptionSections( val proposals: List, val active: List, @@ -502,11 +513,7 @@ fun SubscriptionDetailScreen( } } item { SubscriptionDetailsGrid(subscription, now) } - val payments = if (subscription.isCreatedByUser) { - subscription.receivedPaymentRequests() - } else { - paymentHistory.filter { it.belongsTo(subscription) } - } + val payments = subscriptionPayments(subscription, paymentHistory) if (payments.isNotEmpty()) { item { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index a8225b6b06..7ef2ca1a57 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -55,6 +55,7 @@ import to.bitkit.models.PaykitRequestPricing import to.bitkit.services.PaykitPaymentRequestProposalTerms import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.screens.subscriptions.subscriptionPayments import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse @@ -1174,7 +1175,8 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat sut.refresh().getOrThrow() assertTrue(sut.pendingRequests.value.isEmpty()) - val unpaid = requireNotNull(sut.subscriptions.value.single { it.isPayer }.paymentDueOnAcceptance(clock.now())) + val payerSubscription = sut.subscriptions.value.single { it.isPayer } + val unpaid = requireNotNull(payerSubscription.paymentDueOnAcceptance(clock.now())) assertTrue(unpaid.isPaymentDeadlineExpired(clock.now())) assertTrue(!unpaid.payment(MethodId.Bolt11, clock.now()).isValid(clock.now())) assertEquals(2, sut.subscriptions.value.size) @@ -1189,7 +1191,11 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat } assertEquals(PaymentRequestLifecycleState.PROOF_SUBMITTED, paid.lifecycleState) assertEquals(PaykitPaymentProofKind.Lightning, paid.paymentProofKind) - assertEquals(1, sut.subscriptions.value.single { it.isCreatedByUser }.receivedPaymentRequests().size) + assertEquals(listOf(paid), subscriptionPayments(payerSubscription, sut.paymentRequestHistory.value)) + val creatorSubscription = sut.subscriptions.value.single { it.isCreatedByUser } + val received = subscriptionPayments(creatorSubscription, sut.paymentRequestHistory.value) + assertEquals(creatorSubscription.receivedPaymentRequests(), received) + assertEquals(1, received.size) whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( @@ -1228,11 +1234,13 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat runCurrent() assertTrue(sut.pendingRequests.value.isEmpty()) assertEquals(listOf(request), sut.paymentRequestHistory.value) + assertTrue(subscriptionPayments(sut.subscriptions.value.single(), sut.paymentRequestHistory.value).isEmpty()) assertEquals(PaykitPaymentRequestError.RequestExpired, sut.ensurePaymentAllowed(request).exceptionOrNull()) sut.refresh().getOrThrow() assertTrue(sut.pendingRequests.value.isEmpty()) assertEquals(listOf(request), sut.paymentRequestHistory.value) + assertTrue(subscriptionPayments(sut.subscriptions.value.single(), sut.paymentRequestHistory.value).isEmpty()) sut.clear() assertTrue(sut.paymentRequestHistory.value.isEmpty()) whenever(presentationStore.loadSubscriptionState(LOCAL_IDENTITY)).thenReturn( @@ -1242,6 +1250,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat sut.refresh().getOrThrow() assertTrue(sut.pendingRequests.value.isEmpty()) assertEquals(listOf(request), sut.paymentRequestHistory.value) + assertTrue(subscriptionPayments(sut.subscriptions.value.single(), sut.paymentRequestHistory.value).isEmpty()) advanceTimeBy((nextPeriod - clock.now()).inWholeMilliseconds) sut.refresh().getOrThrow() val next = sut.pendingRequests.value.single() @@ -1249,6 +1258,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat assertEquals(nextPeriod, next.billingPeriod?.startsAt) assertFalse(next.isExpired(clock.now())) assertEquals(listOf(request), sut.paymentRequestHistory.value) + assertTrue(subscriptionPayments(sut.subscriptions.value.single(), sut.paymentRequestHistory.value).isEmpty()) } @Test diff --git a/changelog.d/next/1540.fixed.md b/changelog.d/next/1540.fixed.md index 402f44359b..41d7bc26c1 100644 --- a/changelog.d/next/1540.fixed.md +++ b/changelog.d/next/1540.fixed.md @@ -1 +1 @@ -Eligible initial USDT subscription payments continue after subscribing without a second swipe while retaining authentication and safety checks. Unpaid periods show when they can be retried, and expired periods remain visible in payment history. +Eligible initial USDT subscription payments continue after subscribing without a second swipe while retaining authentication and safety checks, with retryable unpaid periods, expired requests retained in payment history, and only paid periods shown in subscription details. diff --git a/journeys/subscriptions/review-and-subscribe.xml b/journeys/subscriptions/review-and-subscribe.xml index 8d40366bb5..617a8af066 100644 --- a/journeys/subscriptions/review-and-subscribe.xml +++ b/journeys/subscriptions/review-and-subscribe.xml @@ -36,7 +36,7 @@ Verify the first period has only one payment in activity and is no longer offered for payment. Reopen the subscription and verify its next billing period remains scheduled. Have the creator send another USDT-only proposal. Enable PIN for payments before accepting it. Swipe to subscribe, cancel authentication, and verify no payment was sent. Tap the locked amount and verify the payment review stays open. Use Back to close the payment sheet. Reopen the unpaid period from Payments and verify its detail shows Waiting for payment. Tap Pay, verify the fixed Amount screen, then tap Continue to reach payment review. Verify no completed Subscribe swipe or automatic-payment progress appears. Explicitly authorize payment and verify one payment, not two. Repeat with a USDT-only proposal whose amount exceeds the payer's balance including fees. Swipe to subscribe and verify an insufficient-funds or fee-collection error prevents payment. Close and reopen the unpaid period; verify it remains payable and no payment was recorded. - With an unpaid subscription period whose signed payment deadline is shortly ahead, open its detail and wait past the deadline. Verify it changes from Waiting for payment to Expired with no Pay action, and its row remains in Payments. Refresh and relaunch, then verify the same expired period remains visible once and cannot be paid. When the next billing period starts, verify only the new period offers Pay and the old period remains Expired. + With an unpaid subscription period whose signed payment deadline is shortly ahead, open its detail and wait past the deadline. Verify it changes from Waiting for payment to Expired with no Pay action, and its row remains in Payments. Refresh and relaunch, then verify the same expired period remains visible once and cannot be paid. Open the subscription detail and verify the unpaid expired period is absent from its Payments section while previously paid periods remain. When the next billing period starts, verify only the new period offers Pay, the old period remains Expired in payment-request history, and the subscription detail still lists only paid periods. Return to the Overview tab with both the Bitcoin and USD subscriptions active. Verify Monthly Cost shows one total in the selected display currency, using Bitkit's exchange rate, aligned with Active and Created rather than separate stacked amounts.