From df64c19fd12674692d01bc4da91fe75dc5b68af7 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 26 Aug 2026 13:02:48 -0500 Subject: [PATCH 1/3] feat: add Paykit subscription payments --- .../ui/components/DrawerMenuWidgetsTest.kt | 15 +- .../to/bitkit/ui/components/SheetHostTest.kt | 36 + .../CreatePaymentRequestScreenTest.kt | 48 +- .../PaymentRequestsScreenTest.kt | 77 +- .../subscriptions/SubscriptionsScreenTest.kt | 60 + .../to/bitkit/repositories/LightningRepo.kt | 4 + .../repositories/PaykitPaymentProofRepo.kt | 294 ++++- .../repositories/PaykitPaymentProofStore.kt | 9 + .../PaykitPaymentRequestPresentationStore.kt | 64 + .../repositories/PaykitPaymentRequestRepo.kt | 418 ++++++- .../bitkit/repositories/PaykitSubscription.kt | 305 +++++ ...PaykitSubscriptionNotificationScheduler.kt | 155 +++ .../bitkit/repositories/PrivatePaykitRepo.kt | 14 +- .../to/bitkit/services/PaykitSdkService.kt | 18 +- app/src/main/java/to/bitkit/ui/ContentView.kt | 110 +- .../main/java/to/bitkit/ui/MainActivity.kt | 22 + .../main/java/to/bitkit/ui/Notifications.kt | 11 +- .../to/bitkit/ui/components/DrawerMenu.kt | 19 +- .../java/to/bitkit/ui/components/Money.kt | 3 +- .../java/to/bitkit/ui/components/SheetHost.kt | 14 +- .../main/java/to/bitkit/ui/components/Tag.kt | 40 + .../screens/contacts/ContactDetailScreen.kt | 110 +- .../CreatePaymentRequestScreen.kt | 362 +++--- .../IncomingPaymentRequestDetailsScreen.kt | 322 ++++++ .../paymentrequests/PaymentRequestsScreen.kt | 161 ++- .../subscriptions/SubscriptionsScreen.kt | 1029 +++++++++++++++++ .../components/CustomTabRowWithSpacing.kt | 30 +- .../wallets/receive/ReceiveQrScreen.kt | 24 +- .../screens/wallets/receive/ReceiveSheet.kt | 115 +- .../screens/wallets/send/SendConfirmScreen.kt | 78 +- .../screens/wallets/send/SendErrorScreen.kt | 10 +- .../screens/wallets/send/SendPendingScreen.kt | 9 +- .../java/to/bitkit/ui/sheets/SendSheet.kt | 68 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 685 ++++++++--- .../res/drawable-nodpi/subscription_clock.png | Bin 0 -> 85117 bytes app/src/main/res/values/strings.xml | 61 +- .../PaykitPaymentProofRepoTest.kt | 301 ++++- ...aykitPaymentRequestRepoSubscriptionTest.kt | 492 ++++++++ .../PaykitPaymentRequestRepoTest.kt | 30 +- .../repositories/PaykitSubscriptionTest.kt | 158 +++ .../repositories/PrivatePaykitRepoTest.kt | 2 +- .../subscriptions/SubscriptionsScreenTest.kt | 80 ++ .../viewmodels/AppViewModelSendFlowTest.kt | 627 +++++++++- .../viewmodels/TransferViewModelTest.kt | 14 + .../next/paykit-subscriptions.added.md | 1 + 45 files changed, 5839 insertions(+), 666 deletions(-) create mode 100644 app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt create mode 100644 app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt create mode 100644 app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt create mode 100644 app/src/main/res/drawable-nodpi/subscription_clock.png create mode 100644 app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt create mode 100644 app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt create mode 100644 app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt create mode 100644 changelog.d/next/paykit-subscriptions.added.md diff --git a/app/src/androidTest/java/to/bitkit/ui/components/DrawerMenuWidgetsTest.kt b/app/src/androidTest/java/to/bitkit/ui/components/DrawerMenuWidgetsTest.kt index ee7570a3c0..80b4c20c8e 100644 --- a/app/src/androidTest/java/to/bitkit/ui/components/DrawerMenuWidgetsTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/components/DrawerMenuWidgetsTest.kt @@ -153,7 +153,7 @@ class DrawerMenuWidgetsTest { } @Test - fun paymentRequestsIsAvailableFromDrawerWhenPaykitIsEnabled() { + fun subscriptionsIsTheOnlyPaykitEntryInDrawerWhenPaykitIsEnabled() { composeTestRule.setContent { val navController = rememberNavController() val drawerState = rememberDrawerState(DrawerValue.Open) @@ -166,8 +166,8 @@ class DrawerMenuWidgetsTest { composable { Text("Home", modifier = Modifier.testTag("HomeRoute")) } - composable { - Text("Payment Requests", modifier = Modifier.testTag("PaymentRequestsRoute")) + composable { + Text("Subscriptions", modifier = Modifier.testTag("SubscriptionsRoute")) } } DrawerMenu( @@ -182,14 +182,13 @@ class DrawerMenuWidgetsTest { } } - composeTestRule.onNodeWithText("REQUESTS").assertIsDisplayed() - composeTestRule.onNodeWithTag("DrawerPaymentRequests").performClick() + composeTestRule.onNodeWithTag("DrawerSubscriptions").performClick() - composeTestRule.onNodeWithTag("PaymentRequestsRoute").assertIsDisplayed() + composeTestRule.onNodeWithTag("SubscriptionsRoute").assertIsDisplayed() } @Test - fun paymentRequestsIsHiddenFromDrawerWhenPaykitIsDisabled() { + fun paykitEntriesAreHiddenFromDrawerWhenPaykitIsDisabled() { composeTestRule.setContent { val navController = rememberNavController() val drawerState = rememberDrawerState(DrawerValue.Open) @@ -207,7 +206,7 @@ class DrawerMenuWidgetsTest { } } - composeTestRule.onNodeWithTag("DrawerPaymentRequests").assertDoesNotExist() + composeTestRule.onNodeWithTag("DrawerSubscriptions").assertDoesNotExist() } } diff --git a/app/src/androidTest/java/to/bitkit/ui/components/SheetHostTest.kt b/app/src/androidTest/java/to/bitkit/ui/components/SheetHostTest.kt index c0be748197..b84401db9d 100644 --- a/app/src/androidTest/java/to/bitkit/ui/components/SheetHostTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/components/SheetHostTest.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.testTag @@ -84,4 +85,39 @@ class SheetHostTest { assertEquals(0, dismissCount) assertEquals(0, backgroundClickCount) } + + @Test + fun programmaticHideDoesNotInvokeDismissalCallback() { + val shouldExpand = mutableStateOf(true) + val visibilityKey = mutableStateOf("subscription") + var dismissCount = 0 + composeTestRule.setContent { + AppThemeSurface { + SheetHost( + shouldExpand = shouldExpand.value, + onDismiss = { dismissCount++ }, + visibilityKey = visibilityKey.value, + sheets = { + Box( + modifier = Modifier + .fillMaxWidth() + .height(320.dp) + .testTag("ProgrammaticSheet") + ) + }, + content = { Box(Modifier.fillMaxSize()) }, + ) + } + } + composeTestRule.onNodeWithTag("ProgrammaticSheet").assertIsDisplayed() + + composeTestRule.runOnIdle { + shouldExpand.value = false + visibilityKey.value = null + } + composeTestRule.mainClock.advanceTimeBy(1_000) + composeTestRule.waitForIdle() + + assertEquals(0, dismissCount) + } } diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt index 026df0fcc9..1cdd2d18e1 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt @@ -22,6 +22,7 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.viewmodels.AmountInputViewModel import kotlin.time.ExperimentalTime import kotlin.time.Instant +import kotlin.test.assertEquals @ComposeUi class CreatePaymentRequestScreenTest { @@ -29,50 +30,67 @@ class CreatePaymentRequestScreenTest { val composeTestRule = createComposeRule() @Test - fun detailsShowsAmountNoteExpiryAndContinue() { + fun detailsShowsAmountNoteExpiryAndSend() { composeTestRule.setContent { AppThemeSurface { PaymentRequestDetailsContent( - amountInputViewModel = AmountInputViewModel(AmountInputHandler.stub()), initialDraft = draft, + contact = PubkyProfile.placeholder(target.publicKey), + isCreating = false, onBack = {}, - onContinue = {}, + onEditAmount = {}, + onSend = {}, ) } } - composeTestRule.onNodeWithTag("PaymentRequestAmountField").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestNote").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestExpiryWeek").assertIsDisplayed() - composeTestRule.onNodeWithTag("PaymentRequestAmountContinue").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestSend").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestNumberPad").assertDoesNotExist() + } - composeTestRule.onNodeWithTag("PaymentRequestEditAmount").performClick() + @Test + fun amountShowsNumberPadAndContinue() { + composeTestRule.setContent { + AppThemeSurface { + PaymentRequestAmountContent( + amountInputViewModel = AmountInputViewModel(AmountInputHandler.stub()), + initialDraft = draft, + contact = PubkyProfile.placeholder(target.publicKey), + onBack = {}, + onContinue = {}, + ) + } + } + composeTestRule.onNodeWithTag("PaymentRequestAmountField").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestNumberPad").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestAmountContinue").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestNote").assertDoesNotExist() } @Test - fun recipientShowsEligibleContactAndSendAction() { + fun recipientShowsEligibleContactAndAdvancesOnSelection() { + var selectedTarget: PaykitPaymentRequestTarget? = null composeTestRule.setContent { AppThemeSurface { PaymentRequestRecipientContent( targets = persistentListOf(target), contacts = persistentListOf(PubkyProfile.placeholder(target.publicKey)), - isCreating = false, - onEditExpiration = {}, + onBack = {}, onPaste = { target.publicKey }, - onSend = {}, + onSelected = { selectedTarget = it }, ) } } composeTestRule.onNodeWithTag("PaymentRequestContact${target.publicKey}").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestRecipientSearch").assertIsDisplayed() - composeTestRule.onNodeWithTag("PaymentRequestEditExpiration").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestRecipientPaste", useUnmergedTree = true).assertIsDisplayed() - composeTestRule.onNodeWithTag("PaymentRequestSend").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestSend").assertDoesNotExist() + composeTestRule.onNodeWithTag("PaymentRequestContact${target.publicKey}").performClick() + assertEquals(target, selectedTarget) composeTestRule.onNodeWithTag("PaymentRequestRecipientSearch").performTextInput("not this contact") @@ -81,11 +99,12 @@ class CreatePaymentRequestScreenTest { @Test fun sentShowsSuccessSurface() { + val contact = PubkyProfile.forDisplay(target.publicKey, "Anna", imageUrl = null) composeTestRule.setContent { AppThemeSurface { PaymentRequestSentContent( request = request.copy(deliveryStatus = PaykitPaymentRequestDeliveryStatus.Sent), - contact = PubkyProfile.placeholder(target.publicKey), + contact = contact, onDone = {}, ) } @@ -94,7 +113,8 @@ class CreatePaymentRequestScreenTest { composeTestRule.onNodeWithTag("PaymentRequestSent").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestSentCheck").assertIsDisplayed() composeTestRule.onNodeWithText("PAYMENT REQUESTED").assertIsDisplayed() - composeTestRule.onNodeWithText("Waiting for payment").assertIsDisplayed() + composeTestRule.onNodeWithText("Anna").assertIsDisplayed() + composeTestRule.onNodeWithText("Dinner").assertIsDisplayed() } private val draft = PaykitPaymentRequestDraft( diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt index 436b703804..a1b6e62917 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt @@ -13,9 +13,15 @@ import com.synonym.paykit.PaymentRequestLifecycleState import kotlinx.collections.immutable.persistentListOf import org.junit.Rule import org.junit.Test +import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.PaykitBillingPeriod import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus import to.bitkit.repositories.PaykitPaymentRequestDirection +import to.bitkit.repositories.PaykitRecurrenceUnit +import to.bitkit.repositories.PaykitSubscription +import to.bitkit.repositories.PaykitSubscriptionMetadata +import to.bitkit.repositories.PaykitSubscriptionRecurrence import to.bitkit.test.annotations.ComposeUi import to.bitkit.ui.theme.AppThemeSurface import kotlin.time.Clock @@ -36,10 +42,12 @@ class PaymentRequestsScreenTest { PaymentRequestsSheetContent( requests = persistentListOf(request), contacts = persistentListOf(), + subscriptions = persistentListOf(), onNotNow = {}, onSeeAll = {}, onPay = {}, - onReject = { Result.success(Unit) }, + onDismiss = { Result.success(Unit) }, + onDetails = {}, ) } } @@ -51,6 +59,37 @@ class PaymentRequestsScreenTest { composeTestRule.onNodeWithText("Dismiss").assertIsDisplayed() } + @Test + fun recurringQueueCardShowsProviderThenSubscriptionName() { + val contact = PubkyProfile.forDisplay(request().counterparty, "Coffee House", imageUrl = null) + val subscription = subscription(note = "Weekly coffee") + val recurringRequest = request(id = subscription.paymentRequestId).copy( + lifecycleState = PaymentRequestLifecycleState.ACTIVE_RECURRING, + billingPeriod = PaykitBillingPeriod( + startsAt = Instant.parse("2027-01-15T08:00:00Z"), + endsAt = Instant.parse("2027-01-22T08:00:00Z"), + ), + ) + + composeTestRule.setContent { + PaymentRequestsTestSurface { + PaymentRequestsSheetContent( + requests = persistentListOf(recurringRequest), + contacts = persistentListOf(contact), + subscriptions = persistentListOf(subscription), + onNotNow = {}, + onSeeAll = {}, + onPay = {}, + onDismiss = { Result.success(Unit) }, + onDetails = {}, + ) + } + } + + composeTestRule.onNodeWithText("Coffee House").assertIsDisplayed() + composeTestRule.onNodeWithText("Weekly coffee").assertIsDisplayed() + } + @Test fun historyGroupsCompletedRequestsAndKeepsActiveOutgoingRequests() { val now = Clock.System.now() @@ -70,11 +109,13 @@ class PaymentRequestsScreenTest { requests = persistentListOf(outgoing, accepted), pending = persistentListOf(), contacts = persistentListOf(), + subscriptions = persistentListOf(), canRequestPayment = true, onBack = {}, onRequestPayment = {}, onPay = {}, - onReject = { Result.success(Unit) }, + onDismiss = { Result.success(Unit) }, + onDetails = {}, ) } } @@ -95,11 +136,13 @@ class PaymentRequestsScreenTest { requests = persistentListOf(), pending = persistentListOf(), contacts = persistentListOf(), + subscriptions = persistentListOf(), canRequestPayment = true, onBack = {}, onRequestPayment = {}, onPay = {}, - onReject = { Result.success(Unit) }, + onDismiss = { Result.success(Unit) }, + onDetails = {}, ) } } @@ -120,11 +163,13 @@ class PaymentRequestsScreenTest { requests = persistentListOf(), pending = persistentListOf(), contacts = persistentListOf(), + subscriptions = persistentListOf(), canRequestPayment = false, onBack = {}, onRequestPayment = {}, onPay = {}, - onReject = { Result.success(Unit) }, + onDismiss = { Result.success(Unit) }, + onDetails = {}, ) } } @@ -132,7 +177,7 @@ class PaymentRequestsScreenTest { composeTestRule.onNodeWithTag("PaymentRequestCreate").assertDoesNotExist() } - private fun request(id: String) = PaykitPaymentRequest( + private fun request(id: String = "request") = PaykitPaymentRequest( paymentRequestId = id, counterparty = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", counterpartyReceiverPath = "bitkit/wallet", @@ -143,6 +188,28 @@ class PaymentRequestsScreenTest { expiresAt = null, acceptedPaymentEndpointIdentifiers = listOf("btc-lightning-bolt11"), ) + + private fun subscription(note: String) = PaykitSubscription( + paymentRequestId = "subscription", + counterparty = request().counterparty, + counterpartyReceiverPath = "bitkit/wallet", + amountValue = "0.00025", + amountSats = 25_000uL, + note = note, + createdAt = Instant.parse("2027-01-15T08:00:00Z"), + proposalExpiresAt = null, + recurrence = PaykitSubscriptionRecurrence( + every = 1, + unit = PaykitRecurrenceUnit.Week, + startsAt = Instant.parse("2027-01-15T08:00:00Z"), + anchor = Instant.parse("2027-01-15T08:00:00Z"), + endsAt = null, + ), + metadata = PaykitSubscriptionMetadata(description = null, benefits = emptyList()), + acceptedPaymentEndpointIdentifiers = listOf("btc-lightning-bolt11"), + lifecycleState = PaymentRequestLifecycleState.ACTIVE_RECURRING, + paidPeriods = emptyList(), + ) } @Composable diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt new file mode 100644 index 0000000000..da6b33ce30 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt @@ -0,0 +1,60 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.ui.screens.subscriptions + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import kotlinx.collections.immutable.persistentListOf +import org.junit.Rule +import org.junit.Test +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.screens.paymentrequests.PaymentRequestsContent +import to.bitkit.ui.theme.AppThemeSurface +import kotlin.test.assertTrue +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@ComposeUi +class SubscriptionsScreenTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun paymentsTabShowsEligibleOneTimeRequestAction() { + var requestedPayment = false + composeTestRule.setContent { + AppThemeSurface { + SubscriptionsContent( + subscriptions = persistentListOf(), + contacts = persistentListOf(), + acceptedAt = { null }, + now = Instant.parse("2027-01-15T08:00:00Z"), + onBack = {}, + initialTab = SubscriptionTab.Payments, + pendingPaymentRequestCount = 0, + onSubscription = {}, + paymentsContent = { + PaymentRequestsContent( + requests = persistentListOf(), + pending = persistentListOf(), + contacts = persistentListOf(), + subscriptions = persistentListOf(), + canRequestPayment = true, + onBack = {}, + onRequestPayment = { requestedPayment = true }, + onPay = {}, + onDismiss = { Result.success(Unit) }, + onDetails = {}, + showsNavigationBar = false, + ) + }, + ) + } + } + + composeTestRule.onNodeWithTag("PaymentRequestCreate").assertIsDisplayed().performClick() + assertTrue(requestedPayment) + } +} diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index c70c1c467b..8d2cddf6c4 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1340,6 +1340,8 @@ class LightningRepo @Inject constructor( channelId: String? = null, isMaxAmount: Boolean = false, tags: List = emptyList(), + beforeSendAttempt: suspend () -> Unit = {}, + onBroadcast: suspend (Txid) -> Unit = {}, ): Result = executeWhenNodeRunning("sendOnChain") { require(address.isNotEmpty()) { "Send address cannot be empty" } @@ -1364,7 +1366,9 @@ class LightningRepo @Inject constructor( Logger.debug("UTXOs selected to spend: $utxosForSend", context = TAG) + beforeSendAttempt() val txId = lightningService.send(address, sats, satsPerVByte, utxosForSend, isMaxAmount) + onBroadcast(txId) val preActivityMetadata = PreActivityMetadata( walletId = WalletScope.default, diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index cf62ca5c1f..b3e99bb2e4 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -1,6 +1,8 @@ package to.bitkit.repositories import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -39,15 +41,58 @@ data class PendingPaykitPaymentProof( val requestId: PaykitPaymentRequestId, val paymentEndpointIdentifier: String, val kind: PaykitPaymentProofKind, + val paymentStarted: Boolean = false, val paymentIdentifier: String? = null, val proofData: String? = null, + val billingPeriod: PaykitBillingPeriod? = null, + val onchainAddress: String? = null, + val onchainAmountSats: ULong? = null, + val onchainMatchingTransactionIdsBeforeAttempt: Set = emptySet(), ) +data class PaykitOnchainPaymentProofResolution( + val identity: String, + val requestId: PaykitPaymentRequestId, + val transactionId: String, +) + +@Singleton +class PaykitOnchainPaymentProofLookup @Inject constructor( + private val lightningRepo: LightningRepo, + private val activityRepo: ActivityRepo, +) { + suspend fun existingTransactionIds(address: String, amountSats: ULong): Set = + matchingTransactionIds(address, amountSats).mapTo(mutableSetOf(), String::lowercase) + + suspend fun transactionId(address: String, amountSats: ULong, excluding: Set): String? = + matchingTransactionIds(address, amountSats).lastOrNull { it.lowercase() !in excluding } + + private suspend fun matchingTransactionIds(address: String, amountSats: ULong): List = buildList { + lightningRepo.getPayments().getOrThrow().forEach { payment -> + val transactionId = payment.onchainTransactionIdForProofLookup() ?: return@forEach + val details = activityRepo.getTransactionDetails(transactionId).getOrNull() + if (details?.outputs?.any { + it.scriptpubkeyAddress == address && it.value >= 0 && it.value.toULong() == amountSats + } == true + ) { + add(transactionId) + } + } + } + + private fun PaymentDetails.onchainTransactionIdForProofLookup(): String? { + if (direction != PaymentDirection.OUTBOUND || status == PaymentStatus.FAILED) return null + return (kind as? PaymentKind.Onchain)?.txid + } +} + @Singleton +@Suppress("TooManyFunctions") class PaykitPaymentProofRepo @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val paykitSdkService: PaykitSdkService, private val lightningRepo: LightningRepo, + private val onchainPaymentLookup: PaykitOnchainPaymentProofLookup, private val store: PaykitPaymentProofStore, ) { companion object { @@ -56,6 +101,8 @@ class PaykitPaymentProofRepo @Inject constructor( } private val operationMutex = Mutex() + private val _onchainPaymentResolution = MutableStateFlow(null) + val onchainPaymentResolution = _onchainPaymentResolution.asStateFlow() suspend fun prepare( request: PaykitPaymentRequest, @@ -65,13 +112,12 @@ class PaykitPaymentProofRepo @Inject constructor( runSuspendCatching { operationMutex.withLock { val proof = pendingProof(request, paymentEndpointIdentifier, kind) - val proofs = loadProofs() - .filterNot { - PubkyPublicKeyFormat.matches(it.identity, proof.identity) && - it.requestId == request.id && - it.paymentIdentifier == null && - it.proofData == null - } + + val currentProofs = loadProofs() + if (currentProofs.any { it.isStartedFor(proof.identity, request.id) }) { + throw PaykitPaymentRequestError.OperationInProgress + } + val proofs = currentProofs + .filterNot { it.isUnstartedFor(proof.identity, request.id) } + proof persist(proofs) } @@ -82,21 +128,56 @@ class PaykitPaymentProofRepo @Inject constructor( withContext(ioDispatcher) { runSuspendCatching { if (!paymentHash.isHex(HASH_BYTE_COUNT)) throw PaykitPaymentRequestError.RequestUnavailable + val identity = currentIdentity() ?: throw PaykitPaymentRequestError.RequestUnavailable operationMutex.withLock { val proofs = loadProofs().toMutableList() val index = proofs.indexOfLast { - it.requestId == request.id && + PubkyPublicKeyFormat.matches(it.identity, identity) && + it.requestId == request.id && it.kind == PaykitPaymentProofKind.Lightning && + !it.paymentStarted && it.paymentIdentifier == null && it.proofData == null } if (index < 0) throw PaykitPaymentRequestError.RequestUnavailable - proofs[index] = proofs[index].copy(paymentIdentifier = paymentHash.lowercase()) + proofs[index] = proofs[index].copy( + paymentStarted = true, + paymentIdentifier = paymentHash.lowercase(), + ) persist(proofs) } }.onFailure { Logger.warn("Failed to associate a Paykit Lightning payment proof", it, context = TAG) } } + suspend fun markOnchainPaymentStarted( + request: PaykitPaymentRequest, + address: String, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + val identity = currentIdentity() ?: throw PaykitPaymentRequestError.RequestUnavailable + val existingTransactionIds = onchainPaymentLookup.existingTransactionIds(address, request.amountSats) + operationMutex.withLock { + val proofs = loadProofs().toMutableList() + val index = proofs.indexOfLast { + PubkyPublicKeyFormat.matches(it.identity, identity) && + it.requestId == request.id && + it.kind == PaykitPaymentProofKind.Onchain && + !it.paymentStarted && + it.paymentIdentifier == null && + it.proofData == null + } + if (index < 0) throw PaykitPaymentRequestError.RequestUnavailable + proofs[index] = proofs[index].copy( + paymentStarted = true, + onchainAddress = address, + onchainAmountSats = request.amountSats, + onchainMatchingTransactionIdsBeforeAttempt = existingTransactionIds, + ) + persist(proofs) + } + }.onFailure { Logger.warn("Failed to mark a Paykit on-chain payment as started", it, context = TAG) } + } + suspend fun completeLightningPayment(paymentHash: String, preimage: String?) = withContext(ioDispatcher) { if (preimage == null) return@withContext if (!preimage.matchesPaymentHash(paymentHash)) { @@ -141,12 +222,18 @@ class PaykitPaymentProofRepo @Inject constructor( return@withContext } + val identity = currentIdentity() ?: return@withContext + val fallbackProof = runSuspendCatching { + pendingProof(request, paymentEndpointIdentifier, PaykitPaymentProofKind.Onchain) + }.getOrNull() operationMutex.withLock { val completion = runSuspendCatching { val proofs = loadProofs().toMutableList() val index = proofs.indexOfLast { - it.requestId == request.id && + PubkyPublicKeyFormat.matches(it.identity, identity) && + it.requestId == request.id && it.kind == PaykitPaymentProofKind.Onchain && + it.paymentStarted && it.paymentIdentifier == null && it.proofData == null } @@ -157,6 +244,11 @@ class PaykitPaymentProofRepo @Inject constructor( ) proofs[index] = proof persistAndSubmit(listOf(proof), proofs) + _onchainPaymentResolution.value = PaykitOnchainPaymentProofResolution( + identity = proof.identity, + requestId = request.id, + transactionId = txid.lowercase(), + ) } completion.onFailure { Logger.warn( @@ -165,14 +257,19 @@ class PaykitPaymentProofRepo @Inject constructor( context = TAG, ) } - if (completion.isFailure) { - runSuspendCatching { - val proof = pendingProof(request, paymentEndpointIdentifier, PaykitPaymentProofKind.Onchain).copy( - paymentIdentifier = txid.lowercase(), - proofData = txid.lowercase(), - ) - submitReady(proof) - }.onFailure { Logger.warn("Failed to complete a Paykit on-chain payment proof", it, context = TAG) } + if (completion.isFailure && fallbackProof != null) { + val proof = fallbackProof.copy( + paymentStarted = true, + paymentIdentifier = txid.lowercase(), + proofData = txid.lowercase(), + ) + runSuspendCatching { submitReady(proof) } + .onFailure { Logger.warn("Failed to complete a Paykit on-chain payment proof", it, context = TAG) } + _onchainPaymentResolution.value = PaykitOnchainPaymentProofResolution( + identity = proof.identity, + requestId = request.id, + transactionId = txid.lowercase(), + ) } } } @@ -181,8 +278,56 @@ class PaykitPaymentProofRepo @Inject constructor( it.kind == PaykitPaymentProofKind.Lightning && it.paymentIdentifier.equals(paymentHash, ignoreCase = true) } - suspend fun cancelPreparation(request: PaykitPaymentRequest) = removeProofs { - it.requestId == request.id && it.paymentIdentifier == null && it.proofData == null + suspend fun failOnchainPayment(request: PaykitPaymentRequest) { + val identity = currentIdentity() ?: return + removeProofs { + PubkyPublicKeyFormat.matches(it.identity, identity) && + it.requestId == request.id && + it.kind == PaykitPaymentProofKind.Onchain && + it.paymentStarted && + it.paymentIdentifier == null && + it.proofData == null + } + } + + suspend fun cancelPreparation(request: PaykitPaymentRequest) { + val identity = currentIdentity() ?: return + removeProofs { + PubkyPublicKeyFormat.matches(it.identity, identity) && + it.requestId == request.id && + !it.paymentStarted && + it.paymentIdentifier == null && + it.proofData == null + } + } + + suspend fun protectedRequestIdsForSubscriptionCancellation( + identity: String, + subscriptionId: PaykitSubscriptionId, + ): Result> = withContext(ioDispatcher) { + runSuspendCatching { + operationMutex.withLock { + val proofs = loadProofs() + val belongsToSubscription: (PendingPaykitPaymentProof) -> Boolean = { + PubkyPublicKeyFormat.matches(it.identity, identity) && + it.requestId.billingPeriodStartsAt != null && + it.requestId.paymentRequestId == subscriptionId.paymentRequestId && + it.requestId.counterparty == subscriptionId.counterparty && + it.requestId.counterpartyReceiverPath == subscriptionId.counterpartyReceiverPath + } + val protectedRequestIds = proofs.filter(belongsToSubscription) + .filter { it.paymentStarted || it.paymentIdentifier != null || it.proofData != null } + .mapTo(mutableSetOf()) { it.requestId } + val remainingProofs = proofs.filter { + !belongsToSubscription(it) || + it.paymentStarted || + it.paymentIdentifier != null || + it.proofData != null + } + if (remainingProofs != proofs) persist(remainingProofs) + protectedRequestIds + } + }.onFailure { Logger.warn("Failed to prepare Paykit subscription cancellation", it, context = TAG) } } suspend fun reconcile() = withContext(ioDispatcher) { @@ -208,12 +353,19 @@ class PaykitPaymentProofRepo @Inject constructor( proof: PendingPaykitPaymentProof, payments: List, ) { - if (proof.proofData != null) { - submitReady(proof) - return + when { + proof.proofData != null -> submitReady(proof) + proof.kind == PaykitPaymentProofKind.Onchain && proof.paymentStarted -> reconcileOnchainProof(proof) + proof.kind == PaykitPaymentProofKind.Lightning -> reconcileLightningProof(proof, payments) } + } + + private suspend fun reconcileLightningProof( + proof: PendingPaykitPaymentProof, + payments: List, + ) { val paymentHash = proof.paymentIdentifier - if (proof.kind != PaykitPaymentProofKind.Lightning || paymentHash == null) return + if (paymentHash == null) return val payment = payments.firstOrNull { it.direction == PaymentDirection.OUTBOUND && it.id.equals(paymentHash, ignoreCase = true) } ?: return @@ -238,24 +390,59 @@ class PaykitPaymentProofRepo @Inject constructor( } } - private suspend fun submitReady(proof: PendingPaykitPaymentProof) { - val proofData = proof.proofData ?: return + private suspend fun reconcileOnchainProof(proof: PendingPaykitPaymentProof) { + val address = proof.onchainAddress ?: return + val amountSats = proof.onchainAmountSats ?: return + val txid = onchainPaymentLookup.transactionId( + address, + amountSats, + excluding = proof.onchainMatchingTransactionIdsBeforeAttempt, + ) ?: return + if (!txid.isHex(HASH_BYTE_COUNT)) return + + val proofs = loadProofs().toMutableList() + val index = proofs.indexOf(proof) + if (index < 0) return + val completed = proof.copy(paymentIdentifier = txid.lowercase(), proofData = txid.lowercase()) + proofs[index] = completed + persistAndSubmit(listOf(completed), proofs) + _onchainPaymentResolution.value = PaykitOnchainPaymentProofResolution( + identity = proof.identity, + requestId = proof.requestId, + transactionId = txid.lowercase(), + ) + } + + fun consumeOnchainPaymentResolution(resolution: PaykitOnchainPaymentProofResolution) { + _onchainPaymentResolution.compareAndSet(resolution, null) + } + + fun clearOnchainPaymentResolution() { + _onchainPaymentResolution.value = null + } + + private suspend fun currentIdentity(): String? = paykitSdkService.identityStatus() + ?.publicKey + ?.let(PubkyPublicKeyFormat::normalized) + + private suspend fun submitReady(proof: PendingPaykitPaymentProof): Boolean { + val proofData = proof.proofData ?: return false val identityStatus = paykitSdkService.identityStatus() if ( identityStatus?.liveSessionAvailable != true || !PubkyPublicKeyFormat.matches(identityStatus.publicKey, proof.identity) ) { - return + return false } val record = paykitSdkService.paymentRequests().firstOrNull { it.paymentRequestId == proof.requestId.paymentRequestId && PubkyPublicKeyFormat.matches(it.counterparty, proof.requestId.counterparty) && it.counterpartyReceiverPath == proof.requestId.counterpartyReceiverPath - } ?: return + } ?: return false val proofJson = proofJson(proof.kind, proofData) val alreadyQueued = record.paymentProofs.any { - it.billingPeriod == null && + it.billingPeriod.matches(proof.billingPeriod) && it.paymentEndpointIdentifier == proof.paymentEndpointIdentifier && it.proof.exportText().proofValues() == proofJson.proofValues() } @@ -266,6 +453,7 @@ class PaykitPaymentProofRepo @Inject constructor( paymentRequestId = proof.requestId.paymentRequestId, paymentEndpointIdentifier = proof.paymentEndpointIdentifier, proofJson = proofJson, + billingPeriod = proof.billingPeriod, ) Logger.info("Queued a Paykit payment proof for private delivery", context = TAG) runSuspendCatching { paykitSdkService.processPendingPrivateMessages() } @@ -280,6 +468,7 @@ class PaykitPaymentProofRepo @Inject constructor( removeProofsLocked { PubkyPublicKeyFormat.matches(it.identity, proof.identity) && it.requestId == proof.requestId } + return true } private suspend fun removeProofs(predicate: (PendingPaykitPaymentProof) -> Boolean) = withContext(ioDispatcher) { @@ -299,15 +488,31 @@ class PaykitPaymentProofRepo @Inject constructor( completedProofs: List, allProofs: List, ) { - runSuspendCatching { persist(allProofs) } + val didPersist = runSuspendCatching { persist(allProofs) } .onFailure { Logger.warn( "Failed to persist a completed Paykit payment proof; attempting immediate delivery", it, context = TAG, ) - } - completedProofs.forEach { submitReady(it) } + }.isSuccess + var hasUndeliveredProof = false + completedProofs.forEach { proof -> + val wasDelivered = runSuspendCatching { submitReady(proof) } + .onFailure { Logger.warn("Failed to queue a Paykit payment proof", it, context = TAG) } + .getOrDefault(false) + hasUndeliveredProof = hasUndeliveredProof || !wasDelivered + } + if (!didPersist && hasUndeliveredProof) { + runSuspendCatching { persist(allProofs) } + .onFailure { + Logger.warn( + "Failed to retain a completed Paykit payment proof for retry", + it, + context = TAG, + ) + } + } } private suspend fun pendingProof( @@ -331,6 +536,7 @@ class PaykitPaymentProofRepo @Inject constructor( requestId = request.id, paymentEndpointIdentifier = paymentEndpointIdentifier, kind = kind, + billingPeriod = request.billingPeriod, ) } @@ -341,6 +547,14 @@ class PaykitPaymentProofRepo @Inject constructor( } } +private fun com.synonym.paykit.BillingPeriod?.matches(period: PaykitBillingPeriod?): Boolean = when { + this == null && period == null -> true + this == null || period == null -> false + else -> runCatching { + kotlin.time.Instant.parse(startsAt) == period.startsAt && kotlin.time.Instant.parse(endsAt) == period.endsAt + }.getOrDefault(false) +} + private fun endpointSupports(identifier: String, kind: PaykitPaymentProofKind): Boolean { val method = MethodId.fromRawValue(identifier) ?: return false return when (kind) { @@ -349,6 +563,22 @@ private fun endpointSupports(identifier: String, kind: PaykitPaymentProofKind): } } +private fun PendingPaykitPaymentProof.isStartedFor( + identity: String, + requestId: PaykitPaymentRequestId, +): Boolean = PubkyPublicKeyFormat.matches(this.identity, identity) && + this.requestId == requestId && + (paymentStarted || paymentIdentifier != null || proofData != null) + +private fun PendingPaykitPaymentProof.isUnstartedFor( + identity: String, + requestId: PaykitPaymentRequestId, +): Boolean = PubkyPublicKeyFormat.matches(this.identity, identity) && + this.requestId == requestId && + !paymentStarted && + paymentIdentifier == null && + proofData == null + private fun proofJson(kind: PaykitPaymentProofKind, data: String): String = buildJsonObject { put("data", JsonPrimitive(data)) put("type", JsonPrimitive(kind.type)) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt index e03a7fe196..b6041c94d7 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt @@ -5,6 +5,7 @@ import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import to.bitkit.data.keychain.Keychain +import to.bitkit.models.PubkyPublicKeyFormat import javax.inject.Inject import javax.inject.Singleton @@ -22,6 +23,14 @@ class PaykitPaymentProofStore @Inject constructor( return Json.decodeFromString(value).proofs } + fun completedRequestIdsAwaitingSubmission(identity: String): Set = load() + .filter { PubkyPublicKeyFormat.matches(it.identity, identity) && it.proofData != null } + .mapTo(mutableSetOf()) { it.requestId } + + fun inFlightRequestIds(identity: String): Set = load() + .filter { PubkyPublicKeyFormat.matches(it.identity, identity) && it.paymentStarted } + .mapTo(mutableSetOf()) { it.requestId } + suspend fun save(proofs: List) { keychain.upsertString( Keychain.Key.PAYKIT_PENDING_PAYMENT_PROOFS.name, diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt index bb28ee0507..25574ccc27 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt @@ -1,3 +1,5 @@ +@file:OptIn(kotlin.time.ExperimentalTime::class) + package to.bitkit.repositories import kotlinx.coroutines.sync.Mutex @@ -11,6 +13,12 @@ import to.bitkit.models.PubkyPublicKeyFormat import javax.inject.Inject import javax.inject.Singleton +data class PaykitSubscriptionPresentationState( + val acceptedAt: Map = emptyMap(), + val presentedProposalIds: Set = emptySet(), + val dismissedPaymentIds: Set = emptySet(), +) + @Singleton class PaykitPaymentRequestPresentationStore @Inject constructor( private val keychain: Keychain, @@ -20,6 +28,20 @@ class PaykitPaymentRequestPresentationStore @Inject constructor( @Serializable private data class State( val idsByIdentity: Map> = emptyMap(), + val subscriptionStatesByIdentity: Map = emptyMap(), + ) + + @Serializable + private data class SubscriptionState( + val acceptances: List = emptyList(), + val presentedProposalIds: List = emptyList(), + val dismissedPaymentIds: List = emptyList(), + ) + + @Serializable + private data class SubscriptionAcceptance( + val id: PaykitSubscriptionId, + val acceptedAt: String, ) fun load(identity: String): Set { @@ -38,4 +60,46 @@ class PaykitPaymentRequestPresentationStore @Inject constructor( keychain.upsertString(Keychain.Key.PAYKIT_PRESENTED_PAYMENT_REQUESTS.name, Json.encodeToString(state)) } } + + fun loadSubscriptionState(identity: String): PaykitSubscriptionPresentationState { + val normalizedIdentity = PubkyPublicKeyFormat.normalized(identity) + ?: return PaykitSubscriptionPresentationState() + val value = keychain.loadString(Keychain.Key.PAYKIT_PRESENTED_PAYMENT_REQUESTS.name) + ?: return PaykitSubscriptionPresentationState() + val state = Json.decodeFromString(value).subscriptionStatesByIdentity[normalizedIdentity] + ?: return PaykitSubscriptionPresentationState() + val acceptedAt = state.acceptances.mapNotNull { acceptance -> + runCatching { kotlin.time.Instant.parse(acceptance.acceptedAt) } + .getOrNull() + ?.let { acceptance.id to it } + } + .toMap() + return PaykitSubscriptionPresentationState( + acceptedAt = acceptedAt, + presentedProposalIds = state.presentedProposalIds.toSet(), + dismissedPaymentIds = state.dismissedPaymentIds.toSet(), + ) + } + + suspend fun saveSubscriptionState( + identity: String, + subscriptionState: PaykitSubscriptionPresentationState, + ) { + mutex.withLock { + val normalizedIdentity = PubkyPublicKeyFormat.normalized(identity) ?: return@withLock + val current = keychain.loadString(Keychain.Key.PAYKIT_PRESENTED_PAYMENT_REQUESTS.name) + ?.let { Json.decodeFromString(it) } + ?: State() + val storedState = SubscriptionState( + acceptances = subscriptionState.acceptedAt.map { SubscriptionAcceptance(it.key, it.value.toString()) }, + presentedProposalIds = subscriptionState.presentedProposalIds.toList(), + dismissedPaymentIds = subscriptionState.dismissedPaymentIds.toList(), + ) + val state = current.copy( + subscriptionStatesByIdentity = current.subscriptionStatesByIdentity + + (normalizedIdentity to storedState), + ) + keychain.upsertString(Keychain.Key.PAYKIT_PRESENTED_PAYMENT_REQUESTS.name, Json.encodeToString(state)) + } + } } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index df6e15cee8..2f37e7ee30 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -57,7 +57,14 @@ data class PaykitPaymentRequestId( val paymentRequestId: String, val counterparty: String, val counterpartyReceiverPath: String, -) + val billingPeriodStartsAt: String? = null, +) { + fun belongsTo(subscription: PaykitSubscription): Boolean = + billingPeriodStartsAt != null && + paymentRequestId == subscription.paymentRequestId && + counterparty == subscription.counterparty && + counterpartyReceiverPath == subscription.counterpartyReceiverPath +} data class PaykitPaymentRequest( val paymentRequestId: String, @@ -72,11 +79,21 @@ data class PaykitPaymentRequest( val deliveryStatus: PaykitPaymentRequestDeliveryStatus? = null, val direction: PaykitPaymentRequestDirection = PaykitPaymentRequestDirection.Incoming, val lifecycleState: PaymentRequestLifecycleState = PaymentRequestLifecycleState.PROPOSED, + val billingPeriod: PaykitBillingPeriod? = null, ) { val id: PaykitPaymentRequestId - get() = PaykitPaymentRequestId(paymentRequestId, counterparty, counterpartyReceiverPath) + get() = PaykitPaymentRequestId( + paymentRequestId, + counterparty, + counterpartyReceiverPath, + billingPeriod?.startsAt?.toString(), + ) - fun isExpired(now: Instant): Boolean = expiresAt?.let { it <= now } == true + val requiresAcceptance: Boolean + get() = billingPeriod == null && lifecycleState == PaymentRequestLifecycleState.PROPOSED + + fun isExpired(now: Instant): Boolean = + lifecycleState == PaymentRequestLifecycleState.PROPOSED && expiresAt?.let { it <= now } == true fun acceptsLightningInvoiceAmountMsats(amountMsats: ULong?): Boolean = amountMsats == null || amountMsats == satsToMsat(amountSats) @@ -85,6 +102,12 @@ data class PaykitPaymentRequest( amountSats == 0uL || acceptsPaymentAmount(amountSats) fun acceptsPaymentAmount(amountSats: ULong): Boolean = amountSats == this.amountSats + + fun belongsTo(subscription: PaykitSubscription): Boolean = + billingPeriod != null && + paymentRequestId == subscription.paymentRequestId && + counterparty == subscription.counterparty && + counterpartyReceiverPath == subscription.counterpartyReceiverPath } enum class PaykitPaymentRequestDeliveryStatus { Queued, Sent } @@ -114,13 +137,16 @@ sealed class PaykitPaymentRequestError(message: String) : AppError(message) { data object OperationInProgress : PaykitPaymentRequestError("Payment request operation is already in progress") } -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LongParameterList", "LargeClass") @Singleton class PaykitPaymentRequestRepo @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val paykitSdkService: PaykitSdkService, private val settingsStore: SettingsStore, private val presentationStore: PaykitPaymentRequestPresentationStore, + private val paymentProofStore: PaykitPaymentProofStore, + private val paymentProofRepo: PaykitPaymentProofRepo, + private val subscriptionNotificationScheduler: PaykitSubscriptionNotificationScheduler, private val clock: Clock, ) { companion object { @@ -138,6 +164,8 @@ class PaykitPaymentRequestRepo @Inject constructor( val pendingRequests: StateFlow> = _pendingRequests.asStateFlow() private val _paymentRequestHistory = MutableStateFlow>(emptyList()) val paymentRequestHistory: StateFlow> = _paymentRequestHistory.asStateFlow() + private val _subscriptions = MutableStateFlow>(emptyList()) + val subscriptions: StateFlow> = _subscriptions.asStateFlow() private val _eligibleTargets = MutableStateFlow>(emptyList()) val eligibleTargets: StateFlow> = _eligibleTargets.asStateFlow() private val _isCreatingRequest = MutableStateFlow(false) @@ -149,6 +177,18 @@ class PaykitPaymentRequestRepo @Inject constructor( @Volatile private var presentedRequestIds = emptySet() + @Volatile + private var subscriptionAcceptedAt = emptyMap() + + @Volatile + private var presentedSubscriptionProposalIds = emptySet() + + @Volatile + private var dismissedSubscriptionPaymentIds = emptySet() + + @Volatile + private var savedContactPublicKeys = emptyList() + suspend fun activate(identity: String) = withContext(ioDispatcher) { val normalizedIdentity = PubkyPublicKeyFormat.normalized(identity) ?: return@withContext if (!PubkyPublicKeyFormat.matches(activeIdentity, normalizedIdentity)) { @@ -161,15 +201,38 @@ class PaykitPaymentRequestRepo @Inject constructor( presentedRequestIds = runSuspendCatching { presentationStore.load(normalizedIdentity) } .onFailure { Logger.warn("Failed to restore surfaced Paykit payment requests", it, context = TAG) } .getOrDefault(emptySet()) + val subscriptionState = runSuspendCatching { presentationStore.loadSubscriptionState(normalizedIdentity) } + .onFailure { Logger.warn("Failed to restore Paykit subscription state", it, context = TAG) } + .getOrDefault(PaykitSubscriptionPresentationState()) + subscriptionAcceptedAt = subscriptionState.acceptedAt + presentedSubscriptionProposalIds = subscriptionState.presentedProposalIds + dismissedSubscriptionPaymentIds = subscriptionState.dismissedPaymentIds } } fun automaticPendingRequests(): List = _pendingRequests.value.filterNot { it.id in presentedRequestIds } + fun subscriptionProposals(): List = + _subscriptions.value.filter { it.isProposalVisible(clock.now()) } + + fun automaticSubscriptionProposals(): List = + subscriptionProposals().filterNot { it.id in presentedSubscriptionProposalIds } + fun pendingRequest(id: PaykitPaymentRequestId): PaykitPaymentRequest? = _pendingRequests.value.firstOrNull { it.id == id } + fun synchronizeSubscriptionNotifications(enabled: Boolean) { + val identity = activeIdentity ?: return + subscriptionNotificationScheduler.synchronize( + subscriptions = _subscriptions.value, + acceptedAt = { subscriptionAcceptedAt[it.id] }, + pendingRequestIds = _pendingRequests.value.mapTo(mutableSetOf()) { it.id }, + payerIdentity = identity, + notificationsEnabled = enabled, + ) + } + suspend fun markPresented(request: PaykitPaymentRequest): Boolean = withContext(ioDispatcher) { operationMutex.withLock { if (_pendingRequests.value.none { it.id == request.id }) return@withLock false @@ -181,12 +244,53 @@ class PaykitPaymentRequestRepo @Inject constructor( } } + suspend fun markSubscriptionProposalPresented( + subscription: PaykitSubscription, + ): Boolean = withContext(ioDispatcher) { + operationMutex.withLock { + val current = _subscriptions.value.firstOrNull { it.id == subscription.id } + ?.takeIf { it.isProposalVisible(clock.now()) } + ?: return@withLock false + if (current.id in presentedSubscriptionProposalIds) return@withLock true + val identity = activeIdentity ?: return@withLock false + presentedSubscriptionProposalIds = presentedSubscriptionProposalIds + current.id + persistSubscriptionState(identity) + true + } + } + + suspend fun dismissSubscriptionPayment(request: PaykitPaymentRequest): Boolean = withContext(ioDispatcher) { + operationMutex.withLock { + if (request.billingPeriod == null || _pendingRequests.value.none { it.id == request.id }) { + return@withLock false + } + val identity = activeIdentity ?: return@withLock false + dismissedSubscriptionPaymentIds = dismissedSubscriptionPaymentIds + request.id + _pendingRequests.update { requests -> requests.filterNot { it.id == request.id } } + presentedRequestIds = presentedRequestIds - request.id + runSuspendCatching { + presentationStore.saveSubscriptionState(identity, currentSubscriptionState()) + presentationStore.save(identity, presentedRequestIds) + }.onFailure { Logger.warn("Failed to persist dismissed Paykit subscription payment", it, context = TAG) } + subscriptionNotificationScheduler.synchronize( + subscriptions = _subscriptions.value, + acceptedAt = { subscriptionAcceptedAt[it.id] }, + pendingRequestIds = _pendingRequests.value.mapTo(mutableSetOf()) { it.id }, + payerIdentity = identity, + notificationsEnabled = settingsStore.data.first().notificationsGranted, + ) + scheduleExpirationLocked() + true + } + } + suspend fun refresh(savedPublicKeys: List = emptyList()): Result { val generation = stateGeneration.get() val expectedIdentity = activeIdentity return withContext(ioDispatcher) { runSuspendCatching { operationMutex.withLock { + savedContactPublicKeys = savedPublicKeys if (!isAvailable()) { clearStateLocked() return@withLock @@ -273,17 +377,30 @@ class PaykitPaymentRequestRepo @Inject constructor( return PaykitPaymentRequestCreation(request, creatorIdentity, wasPublishedToActiveState) } - suspend fun accept(request: PaykitPaymentRequest): Result = updateRequest( - request = request, - resultingState = PaymentRequestLifecycleState.ACCEPTED, - ) { - paykitSdkService.acceptPaymentRequest( - counterparty = it.counterparty, - counterpartyReceiverPath = it.counterpartyReceiverPath, - paymentRequestId = it.paymentRequestId, - ) - }.onFailure { - Logger.warn("Failed to accept incoming Paykit payment request", it, context = TAG) + suspend fun accept(request: PaykitPaymentRequest): Result { + if (!request.requiresAcceptance) { + return withContext(ioDispatcher) { + runSuspendCatching { + operationMutex.withLock { + if (_pendingRequests.value.none { it.id == request.id }) { + throw PaykitPaymentRequestError.RequestUnavailable + } + } + } + } + } + return updateRequest( + request = request, + resultingState = PaymentRequestLifecycleState.ACCEPTED, + ) { + paykitSdkService.acceptPaymentRequest( + counterparty = it.counterparty, + counterpartyReceiverPath = it.counterpartyReceiverPath, + paymentRequestId = it.paymentRequestId, + ) + }.onFailure { + Logger.warn("Failed to accept incoming Paykit payment request", it, context = TAG) + } } suspend fun reject(request: PaykitPaymentRequest): Result = updateRequest( @@ -299,6 +416,66 @@ class PaykitPaymentRequestRepo @Inject constructor( Logger.warn("Failed to reject incoming Paykit payment request", it, context = TAG) } + suspend fun dismiss(request: PaykitPaymentRequest): Result { + if (request.billingPeriod != null) { + return runSuspendCatching { + if (!dismissSubscriptionPayment(request)) throw PaykitPaymentRequestError.RequestUnavailable + } + } + if (request.requiresAcceptance) return reject(request) + if (request.lifecycleState != PaymentRequestLifecycleState.ACCEPTED) { + return Result.failure(PaykitPaymentRequestError.RequestUnavailable) + } + return updateRequest(request, PaymentRequestLifecycleState.CANCELED) { + paykitSdkService.cancelPaymentRequest( + counterparty = it.counterparty, + counterpartyReceiverPath = it.counterpartyReceiverPath, + paymentRequestId = it.paymentRequestId, + ) + } + } + + fun acceptedAt(subscription: PaykitSubscription): Instant? = subscriptionAcceptedAt[subscription.id] + + suspend fun accept(subscription: PaykitSubscription): Result = withContext(ioDispatcher) { + runSuspendCatching { + operationMutex.withLock { + val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable + val validationDate = clock.now() + val current = _subscriptions.value.firstOrNull { it.id == subscription.id } + ?.takeIf { it == subscription && it.isProposalActionable(validationDate) } + ?: throw PaykitPaymentRequestError.RequestUnavailable + val record = paykitSdkService.acceptPaymentRequest( + current.counterparty, + current.counterpartyReceiverPath, + current.paymentRequestId, + ) + processPendingMessages() + val acceptanceDate = clock.now() + subscriptionAcceptedAt = subscriptionAcceptedAt + (current.id to acceptanceDate) + persistSubscriptionState(identity) + applySubscriptionRecordLocked(record, acceptanceDate) + synchronizeAfterSubscriptionAction(identity) + _pendingRequests.value + .filter { it.belongsTo(current) } + .minByOrNull { it.billingPeriod?.startsAt ?: Instant.DISTANT_FUTURE } + } + }.onFailure { Logger.warn("Failed to accept Paykit subscription", it, context = TAG) } + } + + suspend fun cancel(subscription: PaykitSubscription): Result = updateSubscription(subscription) { + if (!it.isActive(clock.now())) throw PaykitPaymentRequestError.RequestUnavailable + val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable + val protectedRequestIds = paymentProofRepo.protectedRequestIdsForSubscriptionCancellation( + identity = identity, + subscriptionId = it.id, + ).getOrThrow() + if (protectedRequestIds.isNotEmpty()) { + throw PaykitPaymentRequestError.OperationInProgress + } + paykitSdkService.cancelPaymentRequest(it.counterparty, it.counterpartyReceiverPath, it.paymentRequestId) + } + fun isPending(request: PaykitPaymentRequest): Boolean = !request.isExpired(clock.now()) && _pendingRequests.value.any { it.id == request.id } @@ -315,10 +492,14 @@ class PaykitPaymentRequestRepo @Inject constructor( clearStateLocked() activeIdentity = null presentedRequestIds = emptySet() + presentedSubscriptionProposalIds = emptySet() + dismissedSubscriptionPaymentIds = emptySet() + savedContactPublicKeys = emptyList() } } } + @Suppress("LongMethod", "CyclomaticComplexMethod") private suspend fun synchronizeLocked( generation: Long, savedPublicKeys: List, @@ -328,8 +509,60 @@ class PaykitPaymentRequestRepo @Inject constructor( paykitSdkService.receivePrivateMessagesFromLinkedPeers().also(::logIntakeFailures) val now = clock.now() val records = paykitSdkService.paymentRequests() - val incoming = records.mapNotNull { it.toPaykitPaymentRequest(PaymentRequestLocalRole.PAYER, now) } - val history = records.mapNotNull { it.toPaykitPaymentRequestHistory(now) } + val locallyCompletedRequestIds = expectedIdentity + ?.let(paymentProofStore::completedRequestIdsAwaitingSubmission) + .orEmpty() + val locallyInFlightRequestIds = expectedIdentity + ?.let(paymentProofStore::inFlightRequestIds) + .orEmpty() + val subscriptions = records.mapNotNull(PaymentRequestRecord::toPaykitSubscription) + .map { it.withExpiredLifecycle(now) } + val restoredAcceptances = subscriptions + .filter { + it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING || it.paidPeriods.isNotEmpty() + } + .filterNot { it.id in subscriptionAcceptedAt } + .associate { subscription -> + val acceptedAt = subscription.paidPeriods.minOfOrNull { it.startsAt } + ?: subscription.createdAt + ?: now + subscription.id to acceptedAt + } + if (restoredAcceptances.isNotEmpty()) { + subscriptionAcceptedAt = subscriptionAcceptedAt + restoredAcceptances + expectedIdentity?.let { persistSubscriptionState(it) } + } + val recurringRequestsBySubscription = subscriptions.associateWith { requestsThroughAcceptance(it, now) } + val activeRecurringRequestIds = recurringRequestsBySubscription + .filterKeys { it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING } + .values + .flatten() + .mapTo(mutableSetOf()) { it.id } + pruneDismissedSubscriptionPaymentIds(activeRecurringRequestIds, expectedIdentity) + val dueRequests = recurringRequestsBySubscription + .filterKeys { it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING } + .values + .flatten() + .filter { + it.lifecycleState != PaymentRequestLifecycleState.PROOF_SUBMITTED && + it.id !in locallyCompletedRequestIds && + it.id !in locallyInFlightRequestIds && + it.id !in dismissedSubscriptionPaymentIds + } + val recurringHistory = recurringRequestsBySubscription.values.flatten().mapNotNull { request -> + when { + request.lifecycleState == PaymentRequestLifecycleState.PROOF_SUBMITTED -> request + request.id in locallyCompletedRequestIds -> request.copy( + lifecycleState = PaymentRequestLifecycleState.PROOF_SUBMITTED, + ) + else -> null + } + } + val oneTimeIncoming = records.mapNotNull { + it.toPaykitPaymentRequest(PaymentRequestLocalRole.PAYER, now) + }.filter { it.id !in locallyCompletedRequestIds && it.id !in locallyInFlightRequestIds } + val incoming = (dueRequests + oneTimeIncoming).sortedBy { it.createdAt } + val history = (recurringHistory + records.mapNotNull { it.toPaykitPaymentRequestHistory(now) }) .sortedByDescending { it.createdAt } val targets = expectedIdentity?.let { eligibleTargets(savedPublicKeys, it) }.orEmpty() if ( @@ -340,11 +573,28 @@ class PaykitPaymentRequestRepo @Inject constructor( } _pendingRequests.update { incoming } _paymentRequestHistory.update { history } + _subscriptions.update { subscriptions } + prunePresentedSubscriptionProposalIds(subscriptions) + subscriptionNotificationScheduler.synchronize( + subscriptions = subscriptions, + acceptedAt = { subscriptionAcceptedAt[it.id] }, + pendingRequestIds = incoming.mapTo(mutableSetOf()) { it.id }, + payerIdentity = expectedIdentity ?: return, + notificationsEnabled = settingsStore.data.first().notificationsGranted, + ) _eligibleTargets.update { targets } prunePresentedRequestIds(incoming) scheduleExpirationLocked() } + private fun requestsThroughAcceptance( + subscription: PaykitSubscription, + now: Instant, + ): List { + val acceptedAt = subscriptionAcceptedAt[subscription.id] ?: return emptyList() + return subscription.requestsThrough(now, acceptedAt) + } + private fun isCurrentState(generation: Long, expectedIdentity: String?): Boolean = stateGeneration.get() == generation && PubkyPublicKeyFormat.matches(activeIdentity, expectedIdentity) @@ -435,6 +685,69 @@ class PaykitPaymentRequestRepo @Inject constructor( } } + private suspend fun updateSubscription( + subscription: PaykitSubscription, + operation: suspend (PaykitSubscription) -> PaymentRequestRecord, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + operationMutex.withLock { + val current = _subscriptions.value.firstOrNull { it.id == subscription.id } + ?: throw PaykitPaymentRequestError.RequestUnavailable + val record = operation(current) + processPendingMessages() + val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable + applySubscriptionRecordLocked(record, clock.now()) + synchronizeAfterSubscriptionAction(identity) + } + }.onFailure { Logger.warn("Failed to update Paykit subscription", it, context = TAG) } + } + + private suspend fun synchronizeAfterSubscriptionAction(identity: String) { + runSuspendCatching { synchronizeLocked(stateGeneration.get(), savedContactPublicKeys, identity) } + .onFailure { + Logger.warn( + "Failed to refresh Paykit subscriptions after a committed action", + it, + context = TAG, + ) + } + } + + private suspend fun applySubscriptionRecordLocked(record: PaymentRequestRecord, now: Instant) { + val subscription = record.toPaykitSubscription()?.withExpiredLifecycle(now) ?: return + _subscriptions.update { subscriptions -> + subscriptions.filterNot { it.id == subscription.id } + subscription + } + + val recurringRequests = requestsThroughAcceptance(subscription, now) + val unpaidRequests = if (subscription.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING) { + recurringRequests.filter { it.lifecycleState != PaymentRequestLifecycleState.PROOF_SUBMITTED } + } else { + emptyList() + } + val paidRequests = recurringRequests.filter { + it.lifecycleState == PaymentRequestLifecycleState.PROOF_SUBMITTED + } + 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 } + } + prunePresentedSubscriptionProposalIds(_subscriptions.value) + subscriptionNotificationScheduler.synchronize( + subscriptions = _subscriptions.value, + acceptedAt = { subscriptionAcceptedAt[it.id] }, + pendingRequestIds = _pendingRequests.value.mapTo(mutableSetOf()) { it.id }, + payerIdentity = activeIdentity ?: return, + notificationsEnabled = settingsStore.data.first().notificationsGranted, + ) + prunePresentedRequestIds(_pendingRequests.value) + scheduleExpirationLocked() + } + private suspend fun processPendingMessages(): List = runSuspendCatching { paykitSdkService.processPendingPrivateMessages() } .onSuccess(::logOutboundFailures) @@ -475,7 +788,9 @@ class PaykitPaymentRequestRepo @Inject constructor( val now = clock.now() _pendingRequests.update { requests -> requests.filterNot { it.isExpired(now) } } _paymentRequestHistory.update { requests -> requests.withExpiredLifecycle(now) } + _subscriptions.update { subscriptions -> subscriptions.map { it.withExpiredLifecycle(now) } } prunePresentedRequestIds(_pendingRequests.value) + prunePresentedSubscriptionProposalIds(_subscriptions.value) scheduleExpirationLocked() } @@ -489,23 +804,65 @@ class PaykitPaymentRequestRepo @Inject constructor( .onFailure { Logger.warn("Failed to persist surfaced Paykit payment requests", it, context = TAG) } } + private suspend fun prunePresentedSubscriptionProposalIds(subscriptions: List) { + val proposalIds = subscriptions + .filter { it.isProposalVisible(clock.now()) } + .mapTo(mutableSetOf()) { it.id } + val prunedIds = presentedSubscriptionProposalIds.intersect(proposalIds) + if (prunedIds == presentedSubscriptionProposalIds) return + presentedSubscriptionProposalIds = prunedIds + val identity = activeIdentity ?: return + persistSubscriptionState(identity) + } + + private suspend fun pruneDismissedSubscriptionPaymentIds( + activeRequestIds: Set, + identity: String?, + ) { + val prunedIds = dismissedSubscriptionPaymentIds.intersect(activeRequestIds) + if (prunedIds == dismissedSubscriptionPaymentIds) return + dismissedSubscriptionPaymentIds = prunedIds + identity ?: return + persistSubscriptionState(identity) + } + + private fun currentSubscriptionState() = PaykitSubscriptionPresentationState( + acceptedAt = subscriptionAcceptedAt, + presentedProposalIds = presentedSubscriptionProposalIds, + dismissedPaymentIds = dismissedSubscriptionPaymentIds, + ) + + private suspend fun persistSubscriptionState(identity: String) { + runSuspendCatching { presentationStore.saveSubscriptionState(identity, currentSubscriptionState()) } + .onFailure { Logger.warn("Failed to persist Paykit subscription state", it, context = TAG) } + } + private fun clearStateLocked() { expirationJob?.cancel() expirationJob = null _pendingRequests.update { emptyList() } _paymentRequestHistory.update { emptyList() } + _subscriptions.update { emptyList() } _eligibleTargets.update { emptyList() } + savedContactPublicKeys = emptyList() + subscriptionNotificationScheduler.cancel() } private fun scheduleExpirationLocked() { expirationJob?.cancel() expirationJob = null - val nextExpiration = (_pendingRequests.value + _paymentRequestHistory.value) - .asSequence() + val requestExpirations = (_pendingRequests.value + _paymentRequestHistory.value) .filter { it.lifecycleState == PaymentRequestLifecycleState.PROPOSED } .mapNotNull { it.expiresAt } - .minOrNull() + val subscriptionExpirations = _subscriptions.value + .filter { + it.lifecycleState == PaymentRequestLifecycleState.PROPOSED || + it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING + } + .flatMap { listOfNotNull(it.proposalExpiresAt, it.recurrence.endsAt) } + .filter { it > clock.now() } + val nextExpiration = (requestExpirations + subscriptionExpirations).minOrNull() ?: return val delayDuration = (nextExpiration - clock.now()).coerceAtLeast(Duration.ZERO) expirationJob = repoScope.launch { @@ -528,17 +885,23 @@ private fun List.withExpiredLifecycle(now: Instant): List< private val bitcoinAmountPattern = Regex("(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)") -@Suppress("CyclomaticComplexMethod", "ReturnCount") +@Suppress("CyclomaticComplexMethod", "ReturnCount", "LongMethod") private fun PaymentRequestRecord.toPaykitPaymentRequest( expectedRole: PaymentRequestLocalRole, now: Instant, requiresActionableRequest: Boolean = true, ): PaykitPaymentRequest? { if (localRole != expectedRole || state == PaymentRequestLifecycleState.ACTIVE_RECURRING) return null - if (requiresActionableRequest && state != PaymentRequestLifecycleState.PROPOSED) return null + if ( + requiresActionableRequest && + state != PaymentRequestLifecycleState.PROPOSED && + state != PaymentRequestLifecycleState.ACCEPTED + ) { + return null + } val requestTerms = terms ?: return null if (requestTerms.recurrence != null || requestTerms.amount.asset != "btc") return null - val amountSats = requestTerms.amount.value.toSats() + val amountSats = requestTerms.amount.value.toPaykitSats() ?.takeIf { it <= ULong.MAX_VALUE / 1000uL } ?: return null val endpoints = requestTerms.acceptedPaymentEndpointIdentifiers @@ -549,7 +912,10 @@ private fun PaymentRequestRecord.toPaykitPaymentRequest( val expiresAt = requestTerms.proposalExpiresAt?.let { runCatching { Instant.parse(it) }.getOrNull() ?: return null } - if (requiresActionableRequest && expiresAt != null && expiresAt <= now) return null + val isExpiredProposal = state == PaymentRequestLifecycleState.PROPOSED && expiresAt?.let { it <= now } == true + if (requiresActionableRequest && isExpiredProposal) { + return null + } return PaykitPaymentRequest( paymentRequestId = paymentRequestId, @@ -623,7 +989,7 @@ private fun PaymentRequestRecord.toCreatedPaykitPaymentRequest( ) } -private fun PrivateJsonObject.note(): String? = runCatching { +internal fun PrivateJsonObject.note(): String? = runCatching { Json.parseToJsonElement(exportText()) .jsonObject["note"] ?.jsonPrimitive @@ -635,7 +1001,7 @@ private fun PrivateJsonObject.note(): String? = runCatching { private fun ULong.toBitcoinAmount(): String = BigDecimal(toString()).movePointLeft(8).stripTrailingZeros().toPlainString() -private fun String.toSats(): ULong? { +internal fun String.toPaykitSats(): ULong? { if (!bitcoinAmountPattern.matches(this)) return null return runCatching { BigDecimal(this).movePointRight(8).toBigIntegerExact().toString().toULong() diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt new file mode 100644 index 0000000000..1a9046ef72 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt @@ -0,0 +1,305 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.repositories + +import com.synonym.paykit.BillingPeriod +import com.synonym.paykit.PaymentRequestLifecycleState +import com.synonym.paykit.PaymentRequestLocalRole +import com.synonym.paykit.PaymentRequestRecord +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.time.ZoneOffset +import java.time.ZonedDateTime +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@Serializable +data class PaykitBillingPeriod( + val startsAt: Instant, + val endsAt: Instant, +) { + val sdkValue: BillingPeriod + get() = BillingPeriod(startsAt.toString(), endsAt.toString()) +} + +enum class PaykitRecurrenceUnit(val rawValue: String) { + Minute("minute"), + Hour("hour"), + Day("day"), + Week("week"), + Month("month"), + Year("year"); + + val isSupported: Boolean + get() = this !in setOf(Minute, Hour) + + companion object { + fun fromRawValue(value: String): PaykitRecurrenceUnit? = entries.firstOrNull { it.rawValue == value } + } +} + +data class PaykitSubscriptionRecurrence( + val every: Int, + val unit: PaykitRecurrenceUnit, + val startsAt: Instant, + val anchor: Instant, + val endsAt: Instant?, +) { + val canMaterializePeriods: Boolean + get() = firstBoundaryIndexAfter(startsAt) != null + + @Suppress("ReturnCount") + fun periodsThrough(date: Instant, acceptedAt: Instant): List { + if (!unit.isSupported || startsAt > date) return emptyList() + val periods = mutableListOf() + var start = startsAt + var index = firstBoundaryIndexAfter(start) ?: return emptyList() + repeat(MAX_PERIODS) { + if (start > date) return periods + var end = boundary(index++) ?: return periods + if (end <= start) end = addInterval(start) ?: return periods + endsAt?.let { + if (start >= it) return periods + if (end > it) end = it + } + if (end <= start) return periods + if (end > acceptedAt) periods += PaykitBillingPeriod(start, end) + start = end + } + return periods + } + + @Suppress("ReturnCount") + fun nextPeriodAfter(date: Instant): PaykitBillingPeriod? { + var start = startsAt + var index = firstBoundaryIndexAfter(start) ?: return null + repeat(MAX_PERIODS) { + var end = boundary(index++) ?: return null + if (end <= start) end = addInterval(start) ?: return null + endsAt?.let { + if (start >= it) return null + if (end > it) end = it + } + if (start > date) return PaykitBillingPeriod(start, end) + start = end + } + return null + } + + fun upcomingPeriodsAfter(date: Instant, limit: Int): List { + if (limit <= 0) return emptyList() + val periods = mutableListOf() + var cursor = date + repeat(limit.coerceAtMost(MAX_PERIODS)) { + val period = nextPeriodAfter(cursor) ?: return periods + periods += period + cursor = period.startsAt + } + return periods + } + + private fun firstBoundaryIndexAfter(date: Instant): Int? { + var index = 0 + val anchorBoundary = boundary(index) ?: return null + if (anchorBoundary > date) { + while (index > -MAX_PERIODS && boundary(index - 1)?.let { it > date } == true) index-- + } else { + while (index < MAX_PERIODS && boundary(index)?.let { it <= date } == true) index++ + } + + boundary(index)?.takeIf { it > date } ?: return null + boundary(index - 1)?.takeIf { it <= date } ?: return null + return index + } + + private fun boundary(index: Int): Instant? = runCatching { + val value = every.toLong() * index + when (unit) { + PaykitRecurrenceUnit.Minute -> anchor.utc().plusMinutes(value) + PaykitRecurrenceUnit.Hour -> anchor.utc().plusHours(value) + PaykitRecurrenceUnit.Day -> anchor.utc().plusDays(value) + PaykitRecurrenceUnit.Week -> anchor.utc().plusWeeks(value) + PaykitRecurrenceUnit.Month -> anchor.utc().plusMonths(value) + PaykitRecurrenceUnit.Year -> anchor.utc().plusYears(value) + }.toKotlinInstant() + }.getOrNull() + + private fun addInterval(date: Instant): Instant? = runCatching { + val value = every.toLong() + when (unit) { + PaykitRecurrenceUnit.Minute -> date.utc().plusMinutes(value) + PaykitRecurrenceUnit.Hour -> date.utc().plusHours(value) + PaykitRecurrenceUnit.Day -> date.utc().plusDays(value) + PaykitRecurrenceUnit.Week -> date.utc().plusWeeks(value) + PaykitRecurrenceUnit.Month -> date.utc().plusMonths(value) + PaykitRecurrenceUnit.Year -> date.utc().plusYears(value) + }.toKotlinInstant() + }.getOrNull() + + private companion object { + const val MAX_PERIODS = 10_000 + } +} + +data class PaykitSubscriptionMetadata( + val description: String?, + val benefits: List, +) + +@Serializable +data class PaykitSubscriptionId( + val paymentRequestId: String, + val counterparty: String, + val counterpartyReceiverPath: String, +) + +data class PaykitSubscription( + val paymentRequestId: String, + val counterparty: String, + val counterpartyReceiverPath: String, + val amountValue: String, + val amountSats: ULong, + val note: String?, + val createdAt: Instant?, + val proposalExpiresAt: Instant?, + val recurrence: PaykitSubscriptionRecurrence, + val metadata: PaykitSubscriptionMetadata, + val acceptedPaymentEndpointIdentifiers: List, + val lifecycleState: PaymentRequestLifecycleState, + val paidPeriods: List, +) { + val id: PaykitSubscriptionId + get() = PaykitSubscriptionId(paymentRequestId, counterparty, counterpartyReceiverPath) + + fun isProposalVisible(now: Instant): Boolean = + lifecycleState == PaymentRequestLifecycleState.PROPOSED && + proposalExpiresAt?.let { it > now } != false && + recurrence.endsAt?.let { it > now } != false + + fun isProposalActionable(now: Instant): Boolean = + isProposalVisible(now) && + recurrence.unit.isSupported && + recurrence.canMaterializePeriods && + acceptedPaymentEndpointIdentifiers.isNotEmpty() + + fun isActive(now: Instant): Boolean = + lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING && recurrence.endsAt?.let { it > now } != false + + fun isExpired(now: Instant): Boolean = lifecycleState in setOf( + PaymentRequestLifecycleState.CANCELED, + PaymentRequestLifecycleState.REJECTED, + PaymentRequestLifecycleState.PROPOSAL_EXPIRED, + ) || (lifecycleState == PaymentRequestLifecycleState.PROPOSED && proposalExpiresAt?.let { it <= now } == true) || + recurrence.endsAt?.let { it <= now } == true + + fun withExpiredLifecycle(now: Instant): PaykitSubscription = when { + lifecycleState != PaymentRequestLifecycleState.PROPOSED -> this + proposalExpiresAt?.let { it <= now } == true || recurrence.endsAt?.let { it <= now } == true -> { + copy(lifecycleState = PaymentRequestLifecycleState.PROPOSAL_EXPIRED) + } + else -> this + } + + fun requestsThrough(date: Instant, acceptedAt: Instant): List = + recurrence.periodsThrough(date, acceptedAt).map { period -> + PaykitPaymentRequest( + paymentRequestId = paymentRequestId, + counterparty = counterparty, + counterpartyReceiverPath = counterpartyReceiverPath, + amountValue = amountValue, + amountSats = amountSats, + note = note, + createdAt = period.startsAt, + expiresAt = null, + acceptedPaymentEndpointIdentifiers = acceptedPaymentEndpointIdentifiers, + lifecycleState = if (period in paidPeriods) { + PaymentRequestLifecycleState.PROOF_SUBMITTED + } else { + PaymentRequestLifecycleState.ACTIVE_RECURRING + }, + billingPeriod = period, + ) + } + + fun paymentDueOnAcceptance(now: Instant): PaykitPaymentRequest? = requestsThrough(now, now).firstOrNull() +} + +@Suppress("CyclomaticComplexMethod", "ReturnCount") +internal fun PaymentRequestRecord.toPaykitSubscription(): PaykitSubscription? { + if (localRole != PaymentRequestLocalRole.PAYER) return null + val requestTerms = terms ?: return null + val sdkRecurrence = requestTerms.recurrence ?: return null + if ( + requestTerms.amount.asset != "btc" || + sdkRecurrence.every == 0u || + sdkRecurrence.every > Int.MAX_VALUE.toUInt() + ) { + return null + } + val recurrenceUnit = PaykitRecurrenceUnit.fromRawValue(sdkRecurrence.unit) ?: return null + val startsAt = sdkRecurrence.startsAt.parseInstant() ?: return null + val anchor = sdkRecurrence.anchor.parseInstant() ?: return null + val recurrenceEndsAt = sdkRecurrence.endsAt?.parseInstant() + ?: if (sdkRecurrence.endsAt == null) null else return null + val proposalExpiresAt = requestTerms.proposalExpiresAt?.parseInstant() + ?: if (requestTerms.proposalExpiresAt == null) null else return null + if (recurrenceEndsAt != null && recurrenceEndsAt <= startsAt) return null + val amountSats = requestTerms.amount.value.toPaykitSats() + ?.takeIf { it <= ULong.MAX_VALUE / 1000uL } + ?: return null + val endpoints = requestTerms.acceptedPaymentEndpointIdentifiers + .filter { MethodId.fromRawValue(it) != null } + .distinct() + val metadataObject = requestTerms.metadata.subscriptionMetadata() + return PaykitSubscription( + paymentRequestId = paymentRequestId, + counterparty = counterparty, + counterpartyReceiverPath = counterpartyReceiverPath, + amountValue = requestTerms.amount.value, + amountSats = amountSats, + note = requestTerms.metadata.note()?.take(256), + createdAt = lastEventAt?.parseInstant(), + proposalExpiresAt = proposalExpiresAt, + recurrence = PaykitSubscriptionRecurrence( + every = sdkRecurrence.every.toInt(), + unit = recurrenceUnit, + startsAt = startsAt, + anchor = anchor, + endsAt = recurrenceEndsAt, + ), + metadata = metadataObject, + acceptedPaymentEndpointIdentifiers = endpoints, + lifecycleState = state, + paidPeriods = paymentProofs.mapNotNull { proof -> + val period = proof.billingPeriod ?: return@mapNotNull null + val periodStart = period.startsAt.parseInstant() ?: return@mapNotNull null + val periodEnd = period.endsAt.parseInstant() ?: return@mapNotNull null + PaykitBillingPeriod(periodStart, periodEnd).takeIf { periodStart < periodEnd } + }, + ) +} + +private fun com.synonym.paykit.PrivateJsonObject.subscriptionMetadata(): PaykitSubscriptionMetadata = runCatching { + val subscription = Json.parseToJsonElement(exportText()).jsonObject["subscription"]?.jsonObject + ?: return@runCatching PaykitSubscriptionMetadata(null, emptyList()) + if (subscription["version"]?.jsonPrimitive?.contentOrNull != "1") { + return@runCatching PaykitSubscriptionMetadata(null, emptyList()) + } + val description = subscription["description"]?.jsonPrimitive?.contentOrNull?.clean(1024) + val benefits = subscription["benefits"]?.jsonArray.orEmpty() + .take(8) + .mapNotNull { it.jsonPrimitive.contentOrNull?.clean(160) } + PaykitSubscriptionMetadata(description, benefits) +}.getOrDefault(PaykitSubscriptionMetadata(null, emptyList())) + +private fun String.clean(limit: Int): String? = trim().take(limit).takeIf(String::isNotEmpty) + +private fun String.parseInstant(): Instant? = runCatching { Instant.parse(this) }.getOrNull() + +private fun Instant.utc(): ZonedDateTime = java.time.Instant.parse(toString()).atZone(ZoneOffset.UTC) + +private fun ZonedDateTime.toKotlinInstant(): Instant = Instant.parse(toInstant().toString()) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt new file mode 100644 index 0000000000..16a1f11dcb --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt @@ -0,0 +1,155 @@ +@file:OptIn(kotlin.time.ExperimentalTime::class) + +package to.bitkit.repositories + +import android.content.Context +import android.os.Bundle +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ApplicationContext +import to.bitkit.App +import to.bitkit.R +import to.bitkit.ui.EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT +import to.bitkit.ui.EXTRA_PAYKIT_COUNTERPARTY +import to.bitkit.ui.EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH +import to.bitkit.ui.EXTRA_PAYKIT_PAYER_IDENTITY +import to.bitkit.ui.EXTRA_PAYKIT_PAYMENT_REQUEST_ID +import to.bitkit.ui.EXTRA_PAYKIT_SUBSCRIPTION_PAYMENT_DUE +import to.bitkit.ui.pushNotification +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@Singleton +class PaykitSubscriptionNotificationScheduler @Inject constructor( + @ApplicationContext private val context: Context, + private val clock: Clock, +) { + private companion object { + const val MAX_NOTIFICATIONS = 32 + const val PREFERENCES_NAME = "paykit-subscription-notifications" + const val SCHEDULED_WORK_NAMES_KEY = "scheduled-work-names" + const val WORK_PREFIX = "paykit-subscription-" + const val WORK_TAG = "paykit-subscriptions" + } + + private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + private var scheduledWorkNames = preferences.getStringSet(SCHEDULED_WORK_NAMES_KEY, emptySet()).orEmpty() + private var notificationsWereEnabled: Boolean? = null + + @Synchronized + fun synchronize( + subscriptions: List, + acceptedAt: (PaykitSubscription) -> Instant?, + pendingRequestIds: Set, + payerIdentity: String, + notificationsEnabled: Boolean, + ) { + val workManager = WorkManager.getInstance(context) + if (!notificationsEnabled) { + if (notificationsWereEnabled != false) workManager.cancelAllWorkByTag(WORK_TAG) + updateScheduledWorkNames(emptySet()) + notificationsWereEnabled = false + return + } + + val now = clock.now() + val scheduledWork = subscriptions + .filter { + it.isActive(now) && + it.recurrence.unit.isSupported && + acceptedAt(it) != null + } + .flatMap { subscription -> + subscription.recurrence.upcomingPeriodsAfter(now, MAX_NOTIFICATIONS) + .map { subscription to it } + } + .sortedBy { it.second.startsAt } + .take(MAX_NOTIFICATIONS) + .associate { (subscription, period) -> + val workName = "$WORK_PREFIX$payerIdentity|${subscription.counterparty}|" + + "${subscription.counterpartyReceiverPath}|${subscription.paymentRequestId}|${period.startsAt}" + val delay = (period.startsAt - now).inWholeMilliseconds.coerceAtLeast(0) + val work = OneTimeWorkRequestBuilder() + .setInitialDelay(delay, TimeUnit.MILLISECONDS) + .setInputData( + workDataOf( + EXTRA_PAYKIT_PAYMENT_REQUEST_ID to subscription.paymentRequestId, + EXTRA_PAYKIT_PAYER_IDENTITY to payerIdentity, + EXTRA_PAYKIT_COUNTERPARTY to subscription.counterparty, + EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH to subscription.counterpartyReceiverPath, + EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT to period.startsAt.toString(), + ) + ) + .addTag(WORK_TAG) + .build() + workName to work + } + val pendingWorkNames = pendingRequestIds.mapNotNullTo(mutableSetOf()) { requestId -> + requestId.billingPeriodStartsAt?.let { + "$WORK_PREFIX$payerIdentity|${requestId.counterparty}|${requestId.counterpartyReceiverPath}|" + + "${requestId.paymentRequestId}|$it" + } + } + val desiredWorkNames = scheduledWork.keys + scheduledWorkNames.intersect(pendingWorkNames) + (scheduledWorkNames - desiredWorkNames).forEach(workManager::cancelUniqueWork) + scheduledWork + .filterKeys { it !in scheduledWorkNames } + .forEach { (workName, work) -> + workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.KEEP, work) + } + updateScheduledWorkNames(desiredWorkNames) + notificationsWereEnabled = true + } + + @Synchronized + fun cancel() { + WorkManager.getInstance(context).cancelAllWorkByTag(WORK_TAG) + updateScheduledWorkNames(emptySet()) + notificationsWereEnabled = false + } + + private fun updateScheduledWorkNames(workNames: Set) { + scheduledWorkNames = workNames + preferences.edit().putStringSet(SCHEDULED_WORK_NAMES_KEY, workNames).apply() + } +} + +@HiltWorker +class PaykitSubscriptionNotificationWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, +) : CoroutineWorker(appContext, workerParams) { + override suspend fun doWork(): Result { + if (App.currentActivity?.value != null) return Result.success() + applicationContext.pushNotification( + title = applicationContext.getString(R.string.subscriptions__payment_due_title), + text = applicationContext.getString(R.string.subscriptions__payment_due_description), + extras = Bundle().apply { + putBoolean(EXTRA_PAYKIT_SUBSCRIPTION_PAYMENT_DUE, true) + putString(EXTRA_PAYKIT_PAYER_IDENTITY, inputData.getString(EXTRA_PAYKIT_PAYER_IDENTITY)) + putString(EXTRA_PAYKIT_PAYMENT_REQUEST_ID, inputData.getString(EXTRA_PAYKIT_PAYMENT_REQUEST_ID)) + putString(EXTRA_PAYKIT_COUNTERPARTY, inputData.getString(EXTRA_PAYKIT_COUNTERPARTY)) + putString( + EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH, + inputData.getString(EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH), + ) + putString( + EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT, + inputData.getString(EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT), + ) + }, + ) + return Result.success() + } +} diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index fc0ea04eef..e4e3fa6d58 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -89,7 +89,7 @@ class PrivatePaykitRepo @Inject constructor( 90.seconds, ) private val initialLinkBurstRetryDelays = List(14) { 2.seconds } - private val privatePaymentResolutionRetryDelays = privateMessageDrainRetryDelays.take(3) + private val privatePaymentResolutionRetryDelays = initialLinkBurstRetryDelays fun isDuplicatePaymentError(error: Throwable): Boolean = PrivatePaykitErrorClassifier.isDuplicatePaymentError(error) @@ -377,6 +377,18 @@ class PrivatePaykitRepo @Inject constructor( Logger.warn("Failed to present incoming Paykit payment request", it, context = TAG) } + suspend fun beginPaymentRequestWaitingForUpdatedList( + request: PaykitPaymentRequest, + ): Result = runSuspendCatching { + var result = beginPaymentRequest(request).getOrThrow() + for (retryDelay in privatePaymentResolutionRetryDelays) { + if (result != PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) return@runSuspendCatching result + delay(retryDelay) + result = beginPaymentRequest(request).getOrThrow() + } + result + } + suspend fun consumePrivatePaymentList( publicKey: String, context: PrivatePaykitPaymentContext, diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index fcf69c9e75..bc75271ed6 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -655,12 +655,14 @@ class PaykitSdkService @Inject constructor( } } + @Suppress("LongParameterList") suspend fun submitPaymentProof( counterparty: String, counterpartyReceiverPath: String, paymentRequestId: String, paymentEndpointIdentifier: String, proofJson: String, + billingPeriod: to.bitkit.repositories.PaykitBillingPeriod? = null, ): PaymentRequestRecord { isSetup.await() return operationMutex.withLock { @@ -670,7 +672,7 @@ class PaykitSdkService @Inject constructor( counterpartyReceiverPath, paymentRequestId, PaymentProofSubmission( - billingPeriod = null, + billingPeriod = billingPeriod?.sdkValue, paymentEndpointIdentifier = paymentEndpointIdentifier, proof = PrivateJsonObject(proofJson), ), @@ -693,6 +695,20 @@ class PaykitSdkService @Inject constructor( } } + suspend fun cancelPaymentRequest( + counterparty: String, + counterpartyReceiverPath: String, + paymentRequestId: String, + reason: String? = null, + ): PaymentRequestRecord { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.cancelPaymentRequest(counterparty, counterpartyReceiverPath, paymentRequestId, reason) + } + } + } + suspend fun linkedPeers(): List { isSetup.await() return operationMutex.withLock { diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index a8222c8085..ff4cf12f4e 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -59,8 +59,11 @@ import to.bitkit.env.Env import to.bitkit.ext.rawId import to.bitkit.ext.walletId import to.bitkit.models.NodeLifecycleState +import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.Toast import to.bitkit.repositories.ConnectivityState +import to.bitkit.repositories.PaykitPaymentRequestId +import to.bitkit.repositories.PaykitSubscriptionId import to.bitkit.ui.Routes.ExternalConnection import to.bitkit.ui.components.AuthCheckScreen import to.bitkit.ui.components.DefaultSheetContainerColor @@ -91,7 +94,7 @@ import to.bitkit.ui.screens.contacts.ContactsViewModel import to.bitkit.ui.screens.contacts.EditContactScreen import to.bitkit.ui.screens.contacts.EditContactViewModel import to.bitkit.ui.screens.contacts.shouldDiscardPendingImport -import to.bitkit.ui.screens.paymentrequests.PaymentRequestsScreen +import to.bitkit.ui.screens.paymentrequests.IncomingPaymentRequestDetailsScreen import to.bitkit.ui.screens.paymentrequests.PaymentRequestsSheet import to.bitkit.ui.screens.profile.CreateProfileScreen import to.bitkit.ui.screens.profile.CreateProfileViewModel @@ -116,6 +119,9 @@ import to.bitkit.ui.screens.settings.VssDebugScreen import to.bitkit.ui.screens.shop.ShopIntroScreen import to.bitkit.ui.screens.shop.shopDiscover.ShopDiscoverScreen import to.bitkit.ui.screens.shop.shopWebView.ShopWebViewScreen +import to.bitkit.ui.screens.subscriptions.SubscriptionDetailScreen +import to.bitkit.ui.screens.subscriptions.SubscriptionSheet +import to.bitkit.ui.screens.subscriptions.SubscriptionsScreen import to.bitkit.ui.screens.transfer.FundingAdvancedScreen import to.bitkit.ui.screens.transfer.FundingScreen import to.bitkit.ui.screens.transfer.LiquidityScreen @@ -311,6 +317,10 @@ fun ContentView( LaunchedEffect(Unit) { walletViewModel.handleHideBalanceOnOpen() } + LaunchedEffect(notificationsGranted) { + appViewModel.synchronizeSubscriptionNotifications(notificationsGranted) + } + val pendingScreenDeepLink by appViewModel.pendingScreenDeepLink.collectAsStateWithLifecycle() LaunchedEffect(pendingScreenDeepLink) { @@ -519,10 +529,16 @@ fun ContentView( onNotNow = appViewModel::hideSheet, onSeeAll = { appViewModel.hideSheet() - navController.navigateTo(Routes.PaymentRequests) + navController.navigateTo(Routes.Subscriptions(showPayments = true)) + }, + onDetails = { + appViewModel.hideSheet() + navController.navigateTo(it.toRoute()) }, ) + is Sheet.Subscription -> SubscriptionSheet(appViewModel, sheet.route) + is Sheet.ActivityDateRangeSelector -> DateRangeSelectorSheet() is Sheet.ActivityTagSelector -> TagSelectorSheet() is Sheet.Pin -> PinSheet(sheet, appViewModel) @@ -727,14 +743,50 @@ private fun RootNavHost( activityListViewModel = activityListViewModel, navController = navController, ) - composableWithDefaultTransitions { + composableWithDefaultTransitions { backStackEntry -> PaykitRouteGuard(settingsViewModel, navController) { - PaymentRequestsScreen( + val route = backStackEntry.toRoute() + SubscriptionsScreen( appViewModel = appViewModel, onBack = { navController.popBackStack() }, onRequestPayment = { - appViewModel.showSheet(Sheet.Receive(route = ReceiveRoute.PaymentRequestDetails)) + appViewModel.showSheet(Sheet.Receive(route = ReceiveRoute.PaymentRequestRecipient)) }, + onDetails = { + navController.navigateTo( + Routes.SubscriptionDetail( + paymentRequestId = it.paymentRequestId, + counterparty = it.counterparty, + counterpartyReceiverPath = it.counterpartyReceiverPath, + ) + ) + }, + onPaymentRequestDetails = { navController.navigateTo(it.toRoute()) }, + showPayments = route.showPayments, + ) + } + } + composableWithDefaultTransitions { backStackEntry -> + PaykitRouteGuard(settingsViewModel, navController) { + val route = backStackEntry.toRoute() + SubscriptionDetailScreen( + appViewModel = appViewModel, + id = PaykitSubscriptionId( + paymentRequestId = route.paymentRequestId, + counterparty = route.counterparty, + counterpartyReceiverPath = route.counterpartyReceiverPath, + ), + onBack = { navController.popBackStack() }, + ) + } + } + composableWithDefaultTransitions { backStackEntry -> + PaykitRouteGuard(settingsViewModel, navController) { + val route = backStackEntry.toRoute() + IncomingPaymentRequestDetailsScreen( + appViewModel = appViewModel, + id = route.toId(), + onBack = { navController.popBackStack() }, ) } } @@ -1267,6 +1319,10 @@ private fun NavGraphBuilder.contacts( PaykitRouteGuard(settingsViewModel, navController) { val route = backStackEntry.toRoute() val viewModel: ContactDetailViewModel = hiltViewModel() + val paymentRequestTargets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() + val paymentRequestTarget = paymentRequestTargets.firstOrNull { + PubkyPublicKeyFormat.matches(it.publicKey, route.publicKey) + } ContactDetailScreen( viewModel = viewModel, onBackClick = { navController.popBackStack() }, @@ -1274,6 +1330,19 @@ private fun NavGraphBuilder.contacts( appViewModel.openContactPayment(paymentRequest, publicKey, privatePaymentContext) }, onActivityClick = { navController.navigateTo(Routes.ContactActivity(it)) }, + canRequestPayment = paymentRequestTarget != null, + onRequestPayment = { + paymentRequestTarget?.let { + appViewModel.showSheet( + Sheet.Receive( + route = ReceiveRoute.PaymentRequestAmount( + publicKey = it.publicKey, + receiverPath = it.receiverPath, + ) + ) + ) + } + }, showDeleteAction = route.showDeleteAction, onContactDeleted = { navController.navigateTo(Routes.Contacts()) { popUpTo(Routes.Home) } @@ -2000,6 +2069,20 @@ fun NavController.navigateToLanguageSettings() = navigateTo(Routes.LanguageSetti // endregion +private fun PaykitPaymentRequestId.toRoute() = Routes.PaymentRequestDetails( + paymentRequestId = paymentRequestId, + counterparty = counterparty, + counterpartyReceiverPath = counterpartyReceiverPath, + billingPeriodStartsAt = billingPeriodStartsAt, +) + +private fun Routes.PaymentRequestDetails.toId() = PaykitPaymentRequestId( + paymentRequestId = paymentRequestId, + counterparty = counterparty, + counterpartyReceiverPath = counterpartyReceiverPath, + billingPeriodStartsAt = billingPeriodStartsAt, +) + @Stable sealed interface Routes { sealed interface DeepLinkable : Routes @@ -2321,7 +2404,22 @@ sealed interface Routes { data object AllActivity : Routes.DeepLinkable @Serializable - data object PaymentRequests : Routes.InternalOnly + data class Subscriptions(val showPayments: Boolean = false) : Routes.InternalOnly + + @Serializable + data class SubscriptionDetail( + val paymentRequestId: String, + val counterparty: String, + val counterpartyReceiverPath: String, + ) : Routes.InternalOnly + + @Serializable + data class PaymentRequestDetails( + val paymentRequestId: String, + val counterparty: String, + val counterpartyReceiverPath: String, + val billingPeriodStartsAt: String? = null, + ) : Routes.InternalOnly @Serializable data object Trezor : Routes.DeepLinkable diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index d3ad823396..cb122b7244 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -39,6 +39,7 @@ import to.bitkit.androidServices.LightningNodeService.Companion.ACTION_START_SER import to.bitkit.androidServices.LightningNodeService.Companion.CHANNEL_ID_NODE import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.SamRockSetupRequest +import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.ui.components.AuthCheckView import to.bitkit.ui.components.IsOnlineTracker import to.bitkit.ui.components.ToastOverlay @@ -230,6 +231,13 @@ class MainActivity : FragmentActivity() { } private fun handleLaunchIntent(intent: Intent) { + if (intent.getBooleanExtra(EXTRA_PAYKIT_SUBSCRIPTION_PAYMENT_DUE, false)) { + intent.removeExtra(EXTRA_PAYKIT_SUBSCRIPTION_PAYMENT_DUE) + appViewModel.onPaykitSubscriptionNotificationTapped( + payerIdentity = intent.getStringExtra(EXTRA_PAYKIT_PAYER_IDENTITY), + requestId = intent.paykitPaymentRequestId(), + ) + } if (intent.action == UsbManager.ACTION_USB_DEVICE_ATTACHED) { handleUsbAttachIntent(intent) return @@ -242,6 +250,20 @@ class MainActivity : FragmentActivity() { } } + private fun Intent.paykitPaymentRequestId(): PaykitPaymentRequestId? { + val requestId = getStringExtra(EXTRA_PAYKIT_PAYMENT_REQUEST_ID) ?: return null + val counterparty = getStringExtra(EXTRA_PAYKIT_COUNTERPARTY) ?: return null + val receiverPath = getStringExtra(EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH) ?: return null + val billingPeriodStartsAt = getStringExtra(EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT) ?: return null + + return PaykitPaymentRequestId( + paymentRequestId = requestId, + counterparty = counterparty, + counterpartyReceiverPath = receiverPath, + billingPeriodStartsAt = billingPeriodStartsAt, + ) + } + /** * The OS delivers the USB attach event as an activity intent (via the app picker), * not as a broadcast, so it is forwarded from here to trigger the silent reconnect. diff --git a/app/src/main/java/to/bitkit/ui/Notifications.kt b/app/src/main/java/to/bitkit/ui/Notifications.kt index ec9e352936..1e8d7b9668 100644 --- a/app/src/main/java/to/bitkit/ui/Notifications.kt +++ b/app/src/main/java/to/bitkit/ui/Notifications.kt @@ -26,6 +26,12 @@ import kotlin.random.Random const val ID_NOTIFICATION_SKIPPED = -1 const val ID_NOTIFICATION_NODE = 1 +const val EXTRA_PAYKIT_SUBSCRIPTION_PAYMENT_DUE = "paykit_subscription_payment_due" +const val EXTRA_PAYKIT_PAYER_IDENTITY = "paykit_payer_identity" +const val EXTRA_PAYKIT_PAYMENT_REQUEST_ID = "paykit_payment_request_id" +const val EXTRA_PAYKIT_COUNTERPARTY = "paykit_counterparty" +const val EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH = "paykit_counterparty_receiver_path" +const val EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT = "paykit_billing_period_starts_at" val Context.CHANNEL_MAIN get() = getString(R.string.app_notifications_channel_id) @@ -42,6 +48,7 @@ fun Context.initNotificationChannel( internal fun Context.notificationBuilder( extra: Bundle? = null, channelId: String = CHANNEL_MAIN, + requestCode: Int = 0, ): NotificationCompat.Builder { val intent = Intent(this, MainActivity::class.java).apply { flags = FLAG_ACTIVITY_CLEAR_TOP @@ -49,7 +56,7 @@ internal fun Context.notificationBuilder( } val flags = FLAG_IMMUTABLE or FLAG_ONE_SHOT - val pendingIntent = PendingIntent.getActivity(this, 0, intent, flags) + val pendingIntent = PendingIntent.getActivity(this, requestCode, intent, flags) return NotificationCompat.Builder(this, channelId) .setSmallIcon(R.drawable.ic_bitkit_outlined) @@ -74,7 +81,7 @@ internal fun Context.pushNotification( requiresPermission(permission.POST_NOTIFICATIONS) if (!needsPermissionGrant) { - val builder = notificationBuilder(extras) + val builder = notificationBuilder(extras, requestCode = id) .setContentTitle(title) .setContentText(text) .apply { diff --git a/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt b/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt index a0dc4053d6..9e1f5e7d25 100644 --- a/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt +++ b/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt @@ -60,7 +60,7 @@ private const val Z_INDEX_SCRIM = 10f private const val Z_INDEX_MENU = 11f private val bgScrim = Colors.Black50 private val drawerBg = Colors.Brand -private val drawerWidth = 200.dp +private val drawerWidth = 260.dp @Composable fun DrawerMenu( @@ -185,7 +185,7 @@ fun DrawerMenu( onBeforeNavigate(Routes.Home) onOpenWalletHome() }, - showPaymentRequests = isPaykitEnabled, + showSubscriptions = isPaykitEnabled, onBeforeNavigate = onBeforeNavigate, ) } @@ -200,7 +200,7 @@ private fun Menu( onClickContacts: () -> Unit, onClickProfile: () -> Unit, onClickWallet: () -> Unit, - showPaymentRequests: Boolean, + showSubscriptions: Boolean, onBeforeNavigate: (Routes?) -> Unit, ) { val scope = rememberCoroutineScope() @@ -235,16 +235,17 @@ private fun Menu( modifier = Modifier.testTag("DrawerActivity") ) - if (showPaymentRequests) { + if (showSubscriptions) { DrawerItem( - label = stringResource(R.string.wallet__drawer__payment_requests), - iconRes = R.drawable.ic_file_text, + label = stringResource(R.string.subscriptions__title), + iconRes = R.drawable.ic_arrows_clockwise, onClick = { - onBeforeNavigate(Routes.PaymentRequests) - rootNavController.navigateIfNotCurrent(Routes.PaymentRequests) + val route = Routes.Subscriptions() + onBeforeNavigate(route) + rootNavController.navigateIfNotCurrent(route) scope.launch { drawerState.close() } }, - modifier = Modifier.testTag("DrawerPaymentRequests") + modifier = Modifier.testTag("DrawerSubscriptions") ) } diff --git a/app/src/main/java/to/bitkit/ui/components/Money.kt b/app/src/main/java/to/bitkit/ui/components/Money.kt index adffc87ba7..0759a20ddf 100644 --- a/app/src/main/java/to/bitkit/ui/components/Money.kt +++ b/app/src/main/java/to/bitkit/ui/components/Money.kt @@ -46,6 +46,7 @@ fun MoneyDisplay( fun MoneyCell( sats: Long, modifier: Modifier = Modifier, + prefix: String = "", ) { val currencies = LocalCurrencies.current Column( @@ -55,7 +56,7 @@ fun MoneyCell( ) { rememberMoneyText(sats = sats, unit = currencies.primaryDisplay, showSymbol = true)?.let { text -> BodyMSB( - text = text.withAccent(accentColor = Colors.White64), + text = "$prefix$text".withAccent(accentColor = Colors.White64), modifier = Modifier.testTag("MoneyPrimary"), ) } diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index 961637aa32..87d16ee23a 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import kotlinx.coroutines.launch import to.bitkit.models.SamRockSetupRequest +import to.bitkit.repositories.PaykitSubscriptionId import to.bitkit.ui.screens.wallets.receive.ReceiveRoute import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.sheets.BackupRoute @@ -52,11 +53,19 @@ enum class SheetHandlePlacement { ContentOverlay, } +sealed interface SubscriptionRoute { + data class Review(val id: PaykitSubscriptionId) : SubscriptionRoute + data class Success(val id: PaykitSubscriptionId) : SubscriptionRoute + data class Details(val id: PaykitSubscriptionId) : SubscriptionRoute + data class Cancel(val id: PaykitSubscriptionId) : SubscriptionRoute +} + @Stable sealed interface Sheet { data class Send(val route: SendRoute = SendRoute.Recipient) : Sheet data class Receive(val route: ReceiveRoute = ReceiveRoute.QR) : Sheet data object PaymentRequests : Sheet + data class Subscription(val route: SubscriptionRoute) : Sheet data class Pin(val route: PinRoute = PinRoute.Prompt()) : Sheet data object ChangePin : Sheet data object DisablePin : Sheet @@ -126,14 +135,15 @@ fun SheetHost( LaunchedEffect(scaffoldState.bottomSheetState.isVisible, visibilityKey) { if (scaffoldState.bottomSheetState.isVisible) { wasSheetVisible = true - if (visibleKey != visibilityKey) { + if (currentShouldExpand && visibleKey != visibilityKey) { visibleKey = visibilityKey onVisible() } } else if (wasSheetVisible) { + val dismissedKey = visibleKey wasSheetVisible = false visibleKey = null - onDismiss() + if (dismissedKey == visibilityKey) onDismiss() } } diff --git a/app/src/main/java/to/bitkit/ui/components/Tag.kt b/app/src/main/java/to/bitkit/ui/components/Tag.kt index f69178b0fe..18e1aadb2b 100644 --- a/app/src/main/java/to/bitkit/ui/components/Tag.kt +++ b/app/src/main/java/to/bitkit/ui/components/Tag.kt @@ -12,9 +12,15 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow @@ -74,6 +80,40 @@ fun TagButton( } } +@Composable +fun AddTagButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val cornerRadius = 8.dp + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = modifier + .clip(AppShapes.small) + .drawBehind { + drawRoundRect( + color = Colors.White64, + style = Stroke( + width = 1.dp.toPx(), + pathEffect = PathEffect.dashPathEffect(floatArrayOf(4f, 4f)), + ), + cornerRadius = CornerRadius(cornerRadius.toPx()), + ) + } + .clickableAlpha(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + BodySSB(text = stringResource(R.string.wallet__tags_add_button), color = Colors.White) + Icon( + painter = painterResource(R.drawable.ic_plus), + contentDescription = null, + tint = Colors.White64, + modifier = Modifier.size(16.dp), + ) + } +} + @Preview @Composable private fun Preview() { diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt index 7cb936a49e..c3a252d293 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt @@ -1,5 +1,6 @@ package to.bitkit.ui.screens.contacts +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -8,14 +9,20 @@ import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -34,10 +41,15 @@ import to.bitkit.repositories.PrivatePaykitPaymentContext import to.bitkit.ui.components.ActionButton import to.bitkit.ui.components.AddTagSheet import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BottomSheet import to.bitkit.ui.components.CenteredProfileHeader +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.GradientCircularProgressIndicator import to.bitkit.ui.components.LinkRow +import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.SheetSize import to.bitkit.ui.components.TagButton import to.bitkit.ui.components.Text13Up import to.bitkit.ui.components.VerticalSpacer @@ -45,9 +57,13 @@ import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.shared.util.shareText import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent @Composable fun ContactDetailScreen( @@ -55,12 +71,15 @@ fun ContactDetailScreen( onBackClick: () -> Unit, onPayContact: (String, String, PrivatePaykitPaymentContext?) -> Unit, onActivityClick: (String) -> Unit, + canRequestPayment: Boolean = false, + onRequestPayment: () -> Unit = {}, showDeleteAction: Boolean = false, onContactDeleted: () -> Unit = {}, onEditContact: (String) -> Unit = {}, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current + var showRequestOrPay by remember { mutableStateOf(false) } LaunchedEffect(Unit) { viewModel.effects.collect { @@ -79,7 +98,13 @@ fun ContactDetailScreen( showDeleteAction = showDeleteAction, onClickDelete = { viewModel.showDeleteConfirmation() }, onClickCopy = { viewModel.copyPublicKey() }, - onClickPay = { viewModel.payContact() }, + onClickPay = { + if (canRequestPayment) { + showRequestOrPay = true + } else { + viewModel.payContact() + } + }, onClickActivity = { uiState.profile?.publicKey?.let { onActivityClick(it) } }, onClickShare = { uiState.profile?.publicKey?.let { shareText(context, it) } }, onClickRetry = { viewModel.loadContact() }, @@ -90,6 +115,89 @@ fun ContactDetailScreen( onDismissDeleteDialog = { viewModel.dismissDeleteConfirmation() }, onConfirmDelete = { viewModel.deleteContact() }, ) + + if (showRequestOrPay && uiState.profile != null) { + RequestOrPaySheet( + contact = requireNotNull(uiState.profile), + onDismiss = { showRequestOrPay = false }, + onPay = { + showRequestOrPay = false + viewModel.payContact() + }, + onRequest = { + showRequestOrPay = false + onRequestPayment() + }, + ) + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun RequestOrPaySheet( + contact: PubkyProfile, + onDismiss: () -> Unit, + onPay: () -> Unit, + onRequest: () -> Unit, +) { + BottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .sheetHeight(SheetSize.MEDIUM, isModal = true) + .gradientBackground() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + .testTag("RequestOrPaySheet") + ) { + SheetTopBar(titleText = stringResource(R.string.wallet__payment_request_or_pay)) + FillHeight() + Image( + painter = painterResource(R.drawable.coin_stack), + contentDescription = null, + modifier = Modifier + .size(256.dp) + .align(Alignment.CenterHorizontally), + ) + FillHeight() + Display( + text = stringResource(R.string.wallet__payment_request_or_pay_headline) + .withAccent(accentColor = Colors.Purple), + ) + VerticalSpacer(12.dp) + BodyM( + text = stringResource(R.string.wallet__payment_request_or_pay_description, contact.name), + color = Colors.White64, + ) + VerticalSpacer(24.dp) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + SecondaryButton( + text = stringResource(R.string.wallet__payment_request_pay), + onClick = onPay, + icon = { + Icon( + painter = painterResource(R.drawable.ic_sent), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + modifier = Modifier.weight(1f), + ) + PrimaryButton( + text = stringResource(R.string.wallet__payment_request_request), + onClick = onRequest, + icon = { + Icon( + painter = painterResource(R.drawable.ic_received), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + modifier = Modifier.weight(1f), + ) + } + VerticalSpacer(16.dp) + } + } } @Composable diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt index 94e1d431a1..0628bf6a58 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt @@ -3,9 +3,10 @@ package to.bitkit.ui.screens.paymentrequests -import androidx.activity.compose.BackHandler import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -15,6 +16,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -30,10 +32,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.selected -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -45,7 +43,6 @@ import to.bitkit.R import to.bitkit.ext.getClipboardText import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat -import to.bitkit.repositories.AmountInputHandler import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus import to.bitkit.repositories.PaykitPaymentRequestDraft @@ -58,19 +55,25 @@ import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.Display import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.FillWidth +import to.bitkit.ui.components.MoneyCell +import to.bitkit.ui.components.MoneyDisplay import to.bitkit.ui.components.NumberPad import to.bitkit.ui.components.NumberPadTextField import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.PubkyContactAvatar import to.bitkit.ui.components.PubkyContactRow import to.bitkit.ui.components.TextInput +import to.bitkit.ui.components.UnitButton import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.components.rememberMoneyText import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground -import to.bitkit.ui.theme.AppTextStyles import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.removeAccentTags import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.AppViewModel @@ -99,35 +102,33 @@ enum class PaymentRequestExpiration(val duration: Duration) { } @Composable -fun PaymentRequestDetailsScreen( +fun PaymentRequestAmountScreen( amountInputViewModel: AmountInputViewModel, initialDraft: PaykitPaymentRequestDraft, + contact: PubkyProfile?, onBack: () -> Unit, onContinue: (PaykitPaymentRequestDraft) -> Unit, ) { - PaymentRequestDetailsContent( + PaymentRequestAmountContent( amountInputViewModel = amountInputViewModel, initialDraft = initialDraft, + contact = contact, onBack = onBack, onContinue = onContinue, ) } @Composable -internal fun PaymentRequestDetailsContent( +internal fun PaymentRequestAmountContent( modifier: Modifier = Modifier, amountInputViewModel: AmountInputViewModel, initialDraft: PaykitPaymentRequestDraft, + contact: PubkyProfile?, onBack: () -> Unit, onContinue: (PaykitPaymentRequestDraft) -> Unit, ) { val currencies = LocalCurrencies.current val amountState by amountInputViewModel.uiState.collectAsStateWithLifecycle() - var note by remember(initialDraft.note) { mutableStateOf(initialDraft.note) } - var isEditingAmount by remember { mutableStateOf(false) } - var expiration by remember(initialDraft.expiresAt) { - mutableStateOf(PaymentRequestExpiration.from(initialDraft.expiresAt, Clock.System.now())) - } LaunchedEffect(initialDraft.amountSats) { amountInputViewModel.setSats( @@ -136,6 +137,115 @@ internal fun PaymentRequestDetailsContent( ) } + Column( + modifier = modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + .testTag("PaymentRequestAmount") + ) { + SheetTopBar( + titleText = stringResource(R.string.wallet__payment_request_amount), + onBack = onBack, + action = contact?.let { + { + PubkyContactAvatar( + profile = it, + size = 32.dp, + modifier = Modifier.padding(end = 8.dp), + ) + } + }, + ) + BoxWithConstraints(modifier = Modifier.weight(1f)) { + val availableHeight = this.maxHeight + + Column(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) { + VerticalSpacer(16.dp) + rememberMoneyText(sats = amountState.sats, reversed = true, showSymbol = true)?.let { + Caption13Up(text = it.removeAccentTags(), color = Colors.White64) + } + VerticalSpacer(8.dp) + NumberPadTextField( + viewModel = amountInputViewModel, + modifier = Modifier + .fillMaxWidth() + .testTag("PaymentRequestAmountField"), + ) + FillHeight(min = 12.dp) + Row(modifier = Modifier.fillMaxWidth()) { + FillWidth() + UnitButton( + onClick = { amountInputViewModel.switchUnit(currencies) }, + color = Colors.Brand, + modifier = Modifier.testTag("PaymentRequestAmountUnit"), + ) + } + VerticalSpacer(16.dp) + HorizontalDivider(color = Colors.White10) + NumberPad( + viewModel = amountInputViewModel, + currencies = currencies, + availableHeight = availableHeight, + modifier = Modifier.testTag("PaymentRequestNumberPad"), + ) + PrimaryButton( + text = stringResource(R.string.common__continue), + enabled = amountState.sats > 0, + onClick = { + onContinue(initialDraft.copy(amountSats = amountState.sats.toULong())) + }, + modifier = Modifier.testTag("PaymentRequestAmountContinue"), + ) + VerticalSpacer(16.dp) + } + } + } +} + +@Composable +fun PaymentRequestDetailsScreen( + appViewModel: AppViewModel, + draft: PaykitPaymentRequestDraft, + target: PaykitPaymentRequestTarget, + onBack: () -> Unit, + onEditAmount: (PaykitPaymentRequestDraft) -> Unit, + onSent: (PaykitPaymentRequest) -> Unit, +) { + val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() + val isCreating by appViewModel.isCreatingPaymentRequest.collectAsStateWithLifecycle() + val contact = contacts.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, target.publicKey) } + ?: PubkyProfile.placeholder(target.publicKey) + + PaymentRequestDetailsContent( + initialDraft = draft, + contact = contact, + isCreating = isCreating, + onBack = onBack, + onEditAmount = onEditAmount, + onSend = { updatedDraft -> appViewModel.createPaymentRequest(updatedDraft, target, onSent) }, + ) +} + +@Composable +internal fun PaymentRequestDetailsContent( + initialDraft: PaykitPaymentRequestDraft, + contact: PubkyProfile, + isCreating: Boolean, + onBack: () -> Unit, + onEditAmount: (PaykitPaymentRequestDraft) -> Unit, + onSend: (PaykitPaymentRequestDraft) -> Unit, + modifier: Modifier = Modifier, +) { + var note by remember(initialDraft.note) { mutableStateOf(initialDraft.note) } + var expiration by remember(initialDraft.expiresAt) { + mutableStateOf(PaymentRequestExpiration.from(initialDraft.expiresAt, Clock.System.now())) + } + fun updatedDraft(trimNote: Boolean = false) = initialDraft.copy( + note = if (trimNote) note.trim() else note, + expiresAt = Clock.System.now() + expiration.duration, + ) + Column( modifier = modifier .fillMaxSize() @@ -148,22 +258,25 @@ internal fun PaymentRequestDetailsContent( titleText = stringResource(R.string.wallet__payment_request), onBack = onBack, ) - Caption13Up(text = stringResource(R.string.wallet__payment_request_amount), color = Colors.White64) - VerticalSpacer(8.dp) + rememberMoneyText( + sats = initialDraft.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), + reversed = true, + showSymbol = true, + )?.let { + Caption13Up(text = it.removeAccentTags(), color = Colors.White64) + } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(), ) { - NumberPadTextField( - viewModel = amountInputViewModel, - onClick = { isEditingAmount = true }, - modifier = Modifier - .weight(1f) - .testTag("PaymentRequestAmountField"), + MoneyDisplay( + sats = initialDraft.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), + showSymbol = true, ) + FillWidth() IconButton( - onClick = { isEditingAmount = true }, + onClick = { onEditAmount(updatedDraft()) }, modifier = Modifier .size(48.dp) .testTag("PaymentRequestEditAmount"), @@ -176,74 +289,76 @@ internal fun PaymentRequestDetailsContent( ) } } - if (isEditingAmount) { - FillHeight() - NumberPad( - viewModel = amountInputViewModel, - availableHeight = 210.dp, - modifier = Modifier.testTag("PaymentRequestNumberPad"), - ) - VerticalSpacer(12.dp) - PrimaryButton( - text = stringResource(R.string.common__continue), - onClick = { isEditingAmount = false }, - modifier = Modifier.testTag("PaymentRequestAmountDone"), - ) - } else { - VerticalSpacer(20.dp) - Caption13Up(text = stringResource(R.string.wallet__payment_request_note), color = Colors.White64) - VerticalSpacer(8.dp) - TextInput( - value = note, - onValueChange = { note = it.take(256) }, - placeholder = stringResource(R.string.wallet__payment_request_note_placeholder), - maxLines = 2, - modifier = Modifier - .fillMaxWidth() - .testTag("PaymentRequestNote"), - ) - VerticalSpacer(20.dp) - Caption13Up(text = stringResource(R.string.wallet__payment_request_expires), color = Colors.White64) - VerticalSpacer(8.dp) - Row(modifier = Modifier.fillMaxWidth()) { - PaymentRequestExpiration.entries.forEach { option -> - val isSelected = option == expiration - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .weight(1f) - .clickableAlpha { expiration = option } - .semantics { - role = Role.RadioButton - selected = isSelected - } - .testTag("PaymentRequestExpiry${option.name}"), - ) { - BodyS(text = option.title(), color = if (isSelected) Colors.White else Colors.White64) - VerticalSpacer(8.dp) - HorizontalDivider( - thickness = 2.dp, - color = if (isSelected) Colors.White else Colors.White16, - ) - } - } + VerticalSpacer(20.dp) + Caption13Up(text = stringResource(R.string.wallet__payment_request_note), color = Colors.White64) + VerticalSpacer(8.dp) + TextInput( + value = note, + onValueChange = { note = it.take(256) }, + placeholder = stringResource(R.string.wallet__payment_request_note_placeholder), + maxLines = 2, + modifier = Modifier + .fillMaxWidth() + .testTag("PaymentRequestNote"), + ) + VerticalSpacer(20.dp) + Caption13Up(text = stringResource(R.string.wallet__payment_request_recipient), color = Colors.White64) + VerticalSpacer(8.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .background(Colors.Gray6, RoundedCornerShape(16.dp)) + .padding(16.dp), + ) { + PubkyContactAvatar(profile = contact, size = 40.dp) + Column(modifier = Modifier.padding(start = 16.dp).weight(1f)) { + BodyMSB(text = contact.name, maxLines = 1) + BodyS( + text = note.ifBlank { stringResource(R.string.wallet__payment_request) }, + color = Colors.White64, + maxLines = 1, + ) } - FillHeight() - PrimaryButton( - text = stringResource(R.string.wallet__payment_request_choose_recipient), - enabled = amountState.sats > 0, - onClick = { - onContinue( - PaykitPaymentRequestDraft( - amountSats = amountState.sats.toULong(), - note = note.trim(), - expiresAt = Clock.System.now() + expiration.duration, - ) + MoneyCell(sats = initialDraft.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong()) + } + VerticalSpacer(20.dp) + Caption13Up(text = stringResource(R.string.wallet__payment_request_expires), color = Colors.White64) + VerticalSpacer(8.dp) + Row(modifier = Modifier.fillMaxWidth()) { + PaymentRequestExpiration.entries.forEach { option -> + val isSelected = option == expiration + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .weight(1f) + .clickableAlpha { expiration = option } + .testTag("PaymentRequestExpiry${option.name}"), + ) { + BodyS(text = option.title(), color = if (isSelected) Colors.White else Colors.White64) + VerticalSpacer(8.dp) + HorizontalDivider( + thickness = 2.dp, + color = if (isSelected) Colors.White else Colors.White16, ) - }, - modifier = Modifier.testTag("PaymentRequestAmountContinue"), - ) + } + } } + FillHeight() + PrimaryButton( + text = stringResource(R.string.wallet__payment_request_send_request), + enabled = !isCreating, + isLoading = isCreating, + onClick = { onSend(updatedDraft(trimNote = true)) }, + icon = { + Icon( + painter = painterResource(R.drawable.ic_sent), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + modifier = Modifier.testTag("PaymentRequestSend"), + ) VerticalSpacer(16.dp) } } @@ -251,22 +366,19 @@ internal fun PaymentRequestDetailsContent( @Composable fun PaymentRequestRecipientScreen( appViewModel: AppViewModel, - draft: PaykitPaymentRequestDraft, - onEditExpiration: () -> Unit, - onSent: (PaykitPaymentRequest) -> Unit, + onBack: () -> Unit, + onSelected: (PaykitPaymentRequestTarget) -> Unit, ) { val context = LocalContext.current val targets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() - val isCreating by appViewModel.isCreatingPaymentRequest.collectAsStateWithLifecycle() PaymentRequestRecipientContent( targets = targets.toImmutableList(), contacts = contacts.toImmutableList(), - isCreating = isCreating, - onEditExpiration = onEditExpiration, + onBack = onBack, onPaste = { context.getClipboardText()?.trim().orEmpty() }, - onSend = { target -> appViewModel.createPaymentRequest(draft, target, onSent) }, + onSelected = onSelected, ) } @@ -275,12 +387,10 @@ internal fun PaymentRequestRecipientContent( modifier: Modifier = Modifier, targets: ImmutableList, contacts: ImmutableList, - isCreating: Boolean, - onEditExpiration: () -> Unit, + onBack: () -> Unit, onPaste: () -> String, - onSend: (PaykitPaymentRequestTarget) -> Unit, + onSelected: (PaykitPaymentRequestTarget) -> Unit, ) { - var selectedTarget by remember { mutableStateOf(null) } var query by remember { mutableStateOf("") } val recipients = remember(targets, contacts, query) { @@ -294,11 +404,6 @@ internal fun PaymentRequestRecipientContent( } } - LaunchedEffect(recipients) { - if (recipients.none { (target, _) -> target == selectedTarget }) selectedTarget = null - } - BackHandler(enabled = isCreating) {} - Column( modifier = modifier .fillMaxSize() @@ -309,20 +414,7 @@ internal fun PaymentRequestRecipientContent( ) { SheetTopBar( titleText = stringResource(R.string.wallet__payment_request_choose_recipient), - action = { - IconButton( - onClick = onEditExpiration, - enabled = !isCreating, - modifier = Modifier.testTag("PaymentRequestEditExpiration"), - ) { - Icon( - painter = painterResource(R.drawable.ic_timer), - contentDescription = stringResource(R.string.wallet__payment_request_edit_expiration), - tint = Colors.White, - modifier = Modifier.size(24.dp), - ) - } - }, + onBack = onBack, ) Caption13Up(text = stringResource(R.string.wallet__payment_request_recipient), color = Colors.White64) VerticalSpacer(8.dp) @@ -331,7 +423,6 @@ internal fun PaymentRequestRecipientContent( onValueChange = { query = it }, placeholder = stringResource(R.string.wallet__payment_request_enter_pubky), singleLine = true, - textStyle = AppTextStyles.BodyM, trailingIcon = { Row( verticalAlignment = Alignment.CenterVertically, @@ -367,27 +458,13 @@ internal fun PaymentRequestRecipientContent( ) { (target, contact) -> PubkyContactRow( profile = contact, - onClick = { selectedTarget = target }, - isSelected = target == selectedTarget, - isEnabled = !isCreating, + onClick = { onSelected(target) }, verticalPadding = 16.dp, - selectionColor = Colors.Brand, modifier = Modifier.testTag("PaymentRequestContact${contact.publicKey}"), ) HorizontalDivider(color = Colors.White10) } } - PrimaryButton( - text = stringResource(R.string.wallet__payment_request_send_request), - enabled = !isCreating && selectedTarget != null && selectedTarget in targets, - isLoading = isCreating, - onClick = { - val target = selectedTarget ?: return@PrimaryButton - onSend(target) - }, - modifier = Modifier.testTag("PaymentRequestSend"), - ) - VerticalSpacer(16.dp) } } @@ -444,7 +521,9 @@ internal fun PaymentRequestSentContent( PaymentRequestCard( request = request, contact = contact, - compactSubtitle = if (request.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent) { + compactSubtitle = request.note?.takeIf(String::isNotBlank) ?: if ( + request.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent + ) { stringResource(R.string.wallet__payment_request_waiting) } else { stringResource(R.string.wallet__payment_request_sending) @@ -498,10 +577,12 @@ private fun PaymentRequestDetailsPreview() { AppThemeSurface { BottomSheetPreview { PaymentRequestDetailsContent( - amountInputViewModel = AmountInputViewModel(AmountInputHandler.stub()), initialDraft = previewDraft, + contact = PubkyProfile.placeholder(previewTarget.publicKey), + isCreating = false, onBack = {}, - onContinue = {}, + onEditAmount = {}, + onSend = {}, modifier = Modifier.sheetHeight(), ) } @@ -516,10 +597,9 @@ private fun PaymentRequestRecipientPreview() { PaymentRequestRecipientContent( targets = persistentListOf(previewTarget), contacts = persistentListOf(PubkyProfile.placeholder(previewTarget.publicKey)), - isCreating = false, - onEditExpiration = {}, + onBack = {}, onPaste = { "" }, - onSend = {}, + onSelected = {}, modifier = Modifier.sheetHeight(), ) } 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 new file mode 100644 index 0000000000..d068923f5e --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt @@ -0,0 +1,322 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.ui.screens.paymentrequests + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.ext.UiDateStyle +import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.repositories.PaykitPaymentRequest +import to.bitkit.repositories.PaykitPaymentRequestDirection +import to.bitkit.repositories.PaykitPaymentRequestId +import to.bitkit.ui.components.AddTagButton +import to.bitkit.ui.components.AddTagSheet +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.FillWidth +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.PubkyContactAvatar +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.TagButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.components.rememberMoneyText +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.screens.wallets.activity.components.CircularIcon +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.removeAccentTags +import to.bitkit.ui.utils.uiDateText +import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.AppViewModel +import kotlin.time.ExperimentalTime + +@Composable +fun IncomingPaymentRequestDetailsScreen( + appViewModel: AppViewModel, + id: PaykitPaymentRequestId, + onBack: () -> Unit, +) { + val pending by appViewModel.pendingPaymentRequests.collectAsStateWithLifecycle() + val history by appViewModel.paymentRequestHistory.collectAsStateWithLifecycle() + val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() + val request = pending.firstOrNull { it.id == id } ?: history.firstOrNull { it.id == id } + val contact = request?.let { paymentRequest -> + contacts.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, paymentRequest.counterparty) } + ?: PubkyProfile.placeholder(paymentRequest.counterparty) + } + val isPending = pending.any { it.id == id } + + IncomingPaymentRequestDetailsContent( + request = request, + contact = contact, + isPending = isPending, + onBack = onBack, + onPay = { appViewModel.openIncomingPaymentRequestWithTags(id, it) }, + onDismiss = request?.let { { appViewModel.dismissIncomingPaymentRequest(it) } }, + ) +} + +@Composable +private fun IncomingPaymentRequestDetailsContent( + request: PaykitPaymentRequest?, + contact: PubkyProfile?, + isPending: Boolean, + onBack: () -> Unit, + onPay: (List) -> Unit, + onDismiss: (suspend () -> Result)?, +) { + val scope = rememberCoroutineScope() + var isDismissing by remember(request?.id) { mutableStateOf(false) } + var selectedTags by remember(request?.id) { mutableStateOf(emptyList()) } + var isAddingTag by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + .testTag("PaymentRequestDetailsScreen") + ) { + AppTopBar( + titleText = stringResource(R.string.wallet__payment_request), + onBackClick = onBack, + actions = { DrawerNavIcon() }, + ) + if (request == null || contact == null) { + FillHeight() + BodyM( + text = stringResource(R.string.wallet__payment_request_status_unavailable), + color = Colors.White64, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + FillHeight() + return@Column + } + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp), + ) { + VerticalSpacer(16.dp) + rememberMoneyText( + sats = request.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), + reversed = true, + showSymbol = true, + )?.let { + Caption13Up(text = it.removeAccentTags(), color = Colors.White64) + } + rememberMoneyText( + sats = request.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), + showSymbol = true, + )?.let { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Display( + text = "${request.detailsAmountPrefix()}$it".withAccent(accentColor = Colors.White64), + ) + FillWidth() + CircularIcon( + icon = painterResource( + if (request.direction == PaykitPaymentRequestDirection.Incoming) { + R.drawable.ic_received + } else { + R.drawable.ic_sent + } + ), + iconColor = Colors.Purple, + backgroundColor = Colors.Purple16, + size = 48.dp, + ) + } + } + VerticalSpacer(24.dp) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + RequestDetailCell( + title = stringResource(R.string.wallet__payment_request_date), + value = request.createdAt?.let { uiDateText(it.epochSeconds.toULong(), UiDateStyle.DATE) } + ?: stringResource(R.string.wallet__payment_request_status_unavailable), + iconRes = R.drawable.ic_calendar, + modifier = Modifier.weight(1f), + ) + RequestDetailCell( + title = stringResource(R.string.wallet__payment_request_time), + value = request.createdAt?.let { uiDateText(it.epochSeconds.toULong(), UiDateStyle.TIME) } + ?: stringResource(R.string.wallet__payment_request_status_unavailable), + iconRes = R.drawable.ic_clock, + modifier = Modifier.weight(1f), + ) + } + VerticalSpacer(20.dp) + Caption13Up(text = stringResource(R.string.wallet__payment_request_contact), color = Colors.White64) + VerticalSpacer(8.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .background(Colors.Gray6, RoundedCornerShape(16.dp)) + .padding(16.dp), + ) { + PubkyContactAvatar(profile = contact, size = 40.dp) + BodyMSB(text = contact.name, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + VerticalSpacer(20.dp) + PaymentRequestTags( + tags = selectedTags, + onRemove = { selectedTags -= it }, + onAdd = { isAddingTag = true }, + ) + VerticalSpacer(20.dp) + Caption13Up(text = stringResource(R.string.wallet__payment_request_note), color = Colors.White64) + VerticalSpacer(8.dp) + BodyMSB( + text = request.note ?: stringResource(R.string.wallet__payment_request), + modifier = Modifier + .fillMaxWidth() + .background(Colors.Gray6, RoundedCornerShape(16.dp)) + .padding(16.dp), + ) + } + + if (isPending) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), + ) { + SecondaryButton( + text = stringResource(R.string.wallet__payment_request_dismiss), + enabled = !isDismissing, + isLoading = isDismissing, + onClick = { + val dismiss = onDismiss ?: return@SecondaryButton + isDismissing = true + scope.launch { + dismiss().onSuccess { onBack() } + isDismissing = false + } + }, + icon = { + Icon( + painter = painterResource(R.drawable.ic_x), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + modifier = Modifier.weight(1f), + ) + PrimaryButton( + text = stringResource(R.string.wallet__payment_request_pay), + enabled = !isDismissing, + onClick = { onPay(selectedTags) }, + icon = { + Icon( + painter = painterResource(R.drawable.ic_coins), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + modifier = Modifier.weight(1f), + ) + } + } + } + + if (isAddingTag) { + AddTagSheet( + onDismiss = { isAddingTag = false }, + onSave = { tag -> + selectedTags = (selectedTags + tag.trim()).filter(String::isNotBlank).distinct() + isAddingTag = false + }, + ) + } +} + +@Composable +private fun PaymentRequestTags( + tags: List, + onRemove: (String) -> Unit, + onAdd: () -> Unit, +) { + Caption13Up(text = stringResource(R.string.wallet__tags), color = Colors.White64) + VerticalSpacer(8.dp) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + tags.forEach { tag -> + TagButton( + text = tag, + displayIconClose = true, + onClick = { onRemove(tag) }, + ) + } + AddTagButton( + onClick = onAdd, + modifier = Modifier.testTag("PaymentRequestAddTag"), + ) + } +} + +private fun PaykitPaymentRequest.detailsAmountPrefix(): String = + if (direction == PaykitPaymentRequestDirection.Incoming) "-" else "+" + +@Composable +private fun RequestDetailCell( + title: String, + value: String, + @androidx.annotation.DrawableRes iconRes: Int, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Caption13Up(text = title, color = Colors.White64) + VerticalSpacer(8.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = Colors.Purple, + modifier = Modifier.size(16.dp), + ) + BodySSB(text = value, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + VerticalSpacer(12.dp) + HorizontalDivider(color = Colors.White10) + } +} 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 e04df26731..e6e1bfae74 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 @@ -49,6 +49,7 @@ import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus import to.bitkit.repositories.PaykitPaymentRequestDirection import to.bitkit.repositories.PaykitPaymentRequestId +import to.bitkit.repositories.PaykitSubscription import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.BodyS @@ -65,6 +66,8 @@ import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.screens.wallets.activity.components.CircularIcon +import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.shared.util.outerGlow @@ -87,9 +90,11 @@ fun PaymentRequestsSheet( appViewModel: AppViewModel, onNotNow: () -> Unit, onSeeAll: () -> Unit, + onDetails: (PaykitPaymentRequestId) -> Unit, ) { val requests by appViewModel.pendingPaymentRequests.collectAsStateWithLifecycle() val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() + val subscriptions by appViewModel.subscriptions.collectAsStateWithLifecycle() LaunchedEffect(requests.isEmpty()) { if (requests.isEmpty()) onNotNow() @@ -98,10 +103,12 @@ fun PaymentRequestsSheet( PaymentRequestsSheetContent( requests = requests.toImmutableList(), contacts = contacts.toImmutableList(), + subscriptions = subscriptions.toImmutableList(), onNotNow = onNotNow, onSeeAll = onSeeAll, onPay = appViewModel::openIncomingPaymentRequest, - onReject = appViewModel::rejectIncomingPaymentRequest, + onDismiss = appViewModel::dismissIncomingPaymentRequest, + onDetails = onDetails, ) } @@ -110,10 +117,12 @@ internal fun PaymentRequestsSheetContent( modifier: Modifier = Modifier, requests: ImmutableList, contacts: ImmutableList, + subscriptions: ImmutableList, onNotNow: () -> Unit, onSeeAll: () -> Unit, onPay: (PaykitPaymentRequestId) -> Unit, - onReject: suspend (PaykitPaymentRequest) -> Result, + onDismiss: suspend (PaykitPaymentRequest) -> Result, + onDetails: (PaykitPaymentRequestId) -> Unit, ) { Column( modifier = modifier @@ -139,8 +148,10 @@ internal fun PaymentRequestsSheetContent( PaymentRequestCard( request = request, contact = contacts.contactFor(request), + compactSubtitle = subscriptions.nameFor(request), + onClick = { onDetails(request.id) }, onPay = { onPay(request.id) }, - onReject = { onReject(request) }, + onDismiss = { onDismiss(request) }, ) } } @@ -167,21 +178,27 @@ fun PaymentRequestsScreen( appViewModel: AppViewModel, onBack: () -> Unit, onRequestPayment: () -> Unit, + onDetails: (PaykitPaymentRequestId) -> Unit, + showsNavigationBar: Boolean = true, ) { val pending by appViewModel.pendingPaymentRequests.collectAsStateWithLifecycle() val history by appViewModel.paymentRequestHistory.collectAsStateWithLifecycle() val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() val targets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() + val subscriptions by appViewModel.subscriptions.collectAsStateWithLifecycle() PaymentRequestsContent( requests = (pending + history).distinctBy { it.id }.toImmutableList(), pending = pending.toImmutableList(), contacts = contacts.toImmutableList(), + subscriptions = subscriptions.toImmutableList(), canRequestPayment = targets.isNotEmpty(), onBack = onBack, onRequestPayment = onRequestPayment, onPay = appViewModel::openIncomingPaymentRequest, - onReject = appViewModel::rejectIncomingPaymentRequest, + onDismiss = appViewModel::dismissIncomingPaymentRequest, + onDetails = onDetails, + showsNavigationBar = showsNavigationBar, ) } @@ -191,26 +208,36 @@ internal fun PaymentRequestsContent( requests: ImmutableList, pending: ImmutableList, contacts: ImmutableList, + subscriptions: ImmutableList, canRequestPayment: Boolean, onBack: () -> Unit, onRequestPayment: () -> Unit, onPay: (PaykitPaymentRequestId) -> Unit, - onReject: suspend (PaykitPaymentRequest) -> Result, + onDismiss: suspend (PaykitPaymentRequest) -> Result, + onDetails: (PaykitPaymentRequestId) -> Unit, + showsNavigationBar: Boolean = true, ) { val sections = paymentRequestSections(requests, pending, Clock.System.now()) Column( modifier = modifier .fillMaxSize() - .gradientBackground() - .navigationBarsPadding() + .then( + if (showsNavigationBar) { + Modifier.gradientBackground().navigationBarsPadding() + } else { + Modifier + } + ) .testTag("PaymentRequestsScreen") ) { - AppTopBar( - titleText = stringResource(R.string.wallet__payment_requests), - onBackClick = onBack, - actions = { DrawerNavIcon() }, - ) + if (showsNavigationBar) { + AppTopBar( + titleText = stringResource(R.string.wallet__payment_requests), + onBackClick = onBack, + actions = { DrawerNavIcon() }, + ) + } if (requests.isEmpty()) { Column( modifier = Modifier @@ -258,8 +285,10 @@ internal fun PaymentRequestsContent( request = request, isIncoming = pending.any { it.id == request.id }, contact = contacts.contactFor(request), + subscriptionNote = subscriptions.nameFor(request), onPay = onPay, - onReject = onReject, + onDismiss = onDismiss, + onDetails = onDetails, ) } } @@ -274,7 +303,11 @@ internal fun PaymentRequestsContent( PaymentRequestCard( request = request, contact = contacts.contactFor(request), - compactSubtitle = paymentRequestDate(request), + compactSubtitle = subscriptions.nameFor(request) + ?: request.note?.takeIf(String::isNotBlank) + ?: paymentRequestDate(request), + showSignedAmount = true, + onClick = { onDetails(request.id) }, ) } } @@ -306,7 +339,6 @@ private data class PaymentRequestHistorySection( private enum class PaymentRequestHistoryPeriod { Today, - Yesterday, ThisWeek, ThisMonth, ThisYear, @@ -341,21 +373,25 @@ private fun ActivePaymentRequestCard( request: PaykitPaymentRequest, isIncoming: Boolean, contact: PubkyProfile?, + subscriptionNote: String?, onPay: (PaykitPaymentRequestId) -> Unit, - onReject: suspend (PaykitPaymentRequest) -> Result, + onDismiss: suspend (PaykitPaymentRequest) -> Result, + onDetails: (PaykitPaymentRequestId) -> Unit, ) { if (isIncoming) { PaymentRequestCard( request = request, contact = contact, - compactSubtitle = paymentRequestDateTime(request), + compactSubtitle = subscriptionNote, + onClick = { onDetails(request.id) }, onPay = { onPay(request.id) }, - onReject = { onReject(request) }, + onDismiss = { onDismiss(request) }, ) } else { PaymentRequestCard( request = request, contact = contact, + onClick = { onDetails(request.id) }, compactSubtitle = stringResource( R.string.wallet__payment_request_waiting_for_recipient, contact?.name ?: PubkyProfile.placeholder(request.counterparty).name, @@ -367,7 +403,6 @@ private fun ActivePaymentRequestCard( @Composable private fun paymentRequestHistorySectionTitle(period: PaymentRequestHistoryPeriod): String = when (period) { PaymentRequestHistoryPeriod.Today -> stringResource(R.string.wallet__payment_requests_today) - PaymentRequestHistoryPeriod.Yesterday -> stringResource(R.string.wallet__payment_requests_yesterday) PaymentRequestHistoryPeriod.ThisWeek -> stringResource(R.string.wallet__payment_requests_this_week) PaymentRequestHistoryPeriod.ThisMonth -> stringResource(R.string.wallet__payment_requests_this_month) PaymentRequestHistoryPeriod.ThisYear -> stringResource(R.string.wallet__payment_requests_this_year) @@ -387,7 +422,6 @@ private fun PaykitPaymentRequest.historyPeriod( return when { date == today -> PaymentRequestHistoryPeriod.Today - date == today.minusDays(1) -> PaymentRequestHistoryPeriod.Yesterday !date.isBefore(startOfWeek) -> PaymentRequestHistoryPeriod.ThisWeek date.year == today.year && date.month == today.month -> PaymentRequestHistoryPeriod.ThisMonth date.year == today.year -> PaymentRequestHistoryPeriod.ThisYear @@ -400,16 +434,6 @@ private fun paymentRequestDate(request: PaykitPaymentRequest): String = request. uiDateText(it.epochSeconds.toULong(), UiDateStyle.DATE) } ?: paymentRequestStatus(request) -@Composable -private fun paymentRequestDateTime(request: PaykitPaymentRequest): String = request.createdAt?.let { - val timestamp = it.epochSeconds.toULong() - stringResource( - R.string.wallet__payment_request_timestamp, - uiDateText(timestamp, UiDateStyle.DATE), - uiDateText(timestamp, UiDateStyle.TIME), - ) -} ?: paymentRequestStatus(request) - @Composable private fun paymentRequestStatus(request: PaykitPaymentRequest): String { if (request.lifecycleState == PaymentRequestLifecycleState.PROPOSED && request.isExpired(Clock.System.now())) { @@ -450,19 +474,17 @@ internal fun PaymentRequestCard( request: PaykitPaymentRequest, contact: PubkyProfile?, compactSubtitle: String? = null, + isOutgoingPayment: Boolean = false, + showSignedAmount: Boolean = false, + onClick: (() -> Unit)? = null, onPay: (() -> Unit)? = null, - onReject: (suspend () -> Result)? = null, + onDismiss: (suspend () -> Result)? = null, ) { val scope = rememberCoroutineScope() - var isRejecting by remember(request.id) { mutableStateOf(false) } + var isDismissing by remember(request.id) { mutableStateOf(false) } val displayContact = contact ?: PubkyProfile.placeholder(request.counterparty) - val subtitle = compactSubtitle ?: request.createdAt?.let { - val timestamp = it.epochSeconds.toULong() - val date = uiDateText(timestamp, UiDateStyle.DATE) - val time = uiDateText(timestamp, UiDateStyle.TIME) - val formattedTimestamp = stringResource(R.string.wallet__payment_request_timestamp, date, time) - stringResource(R.string.wallet__payment_request_contact_timestamp, displayContact.name, formattedTimestamp) - } ?: displayContact.name + val subtitle = compactSubtitle ?: request.note?.takeIf(String::isNotBlank) ?: paymentRequestDate(request) + val amountPrefix = request.amountPrefix(isOutgoingPayment, showSignedAmount) Card( colors = CardDefaults.cardColors(containerColor = Colors.Gray6), @@ -470,7 +492,7 @@ internal fun PaymentRequestCard( modifier = Modifier .fillMaxWidth() .then( - if (onPay != null || onReject != null) { + if (onPay != null || onDismiss != null) { Modifier .outerGlow( glowColor = Colors.Brand, @@ -483,17 +505,27 @@ internal fun PaymentRequestCard( Modifier } ) + .clickableAlpha(enabled = onClick != null) { onClick?.invoke() } .testTag("PaymentRequestRow${request.paymentRequestId}"), ) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(16.dp), ) { - PubkyContactAvatar(profile = displayContact, size = 40.dp) + if (isOutgoingPayment) { + CircularIcon( + icon = painterResource(R.drawable.ic_sent), + iconColor = Colors.Brand, + backgroundColor = Colors.Brand16, + size = 40.dp, + ) + } else { + PubkyContactAvatar(profile = displayContact, size = 40.dp) + } Column(modifier = Modifier.weight(1f)) { BodyMSB( - text = request.note ?: stringResource(R.string.wallet__payment_request), + text = displayContact.name, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -506,11 +538,12 @@ internal fun PaymentRequestCard( } MoneyCell( sats = request.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), + prefix = amountPrefix, ) } - if (onPay != null || onReject != null) { + if (onPay != null || onDismiss != null) { Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier .fillMaxWidth() .background(Colors.Gray5) @@ -519,15 +552,15 @@ internal fun PaymentRequestCard( SecondaryButton( text = stringResource(R.string.wallet__payment_request_dismiss), onClick = { - if (isRejecting || onReject == null) return@SecondaryButton - isRejecting = true + if (isDismissing || onDismiss == null) return@SecondaryButton + isDismissing = true scope.launch { - onReject() - isRejecting = false + onDismiss() + isDismissing = false } }, - isLoading = isRejecting, - enabled = !isRejecting, + isLoading = isDismissing, + enabled = !isDismissing, icon = { Icon( painter = painterResource(R.drawable.ic_x), @@ -541,7 +574,7 @@ internal fun PaymentRequestCard( PrimaryButton( text = stringResource(R.string.wallet__payment_request_pay), onClick = { onPay?.invoke() }, - enabled = !isRejecting, + enabled = !isDismissing, icon = { Icon( painter = painterResource(R.drawable.ic_coins), @@ -557,11 +590,25 @@ internal fun PaymentRequestCard( } } +private fun PaykitPaymentRequest.amountPrefix(isOutgoingPayment: Boolean, showSignedAmount: Boolean): String = when { + isOutgoingPayment -> "-" + showSignedAmount && direction == PaykitPaymentRequestDirection.Incoming -> "-" + showSignedAmount -> "+" + else -> "" +} + private fun List.contactFor(request: PaykitPaymentRequest): PubkyProfile? = firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, request.counterparty) } +@Composable +private fun List.nameFor(request: PaykitPaymentRequest): String? { + val subscription = firstOrNull(request::belongsTo) ?: return null + return subscription.note?.takeIf(String::isNotBlank) + ?: stringResource(R.string.subscriptions__subscription) +} + private val PaykitPaymentRequest.lazyListKey: String - get() = "$paymentRequestId|$counterparty|$counterpartyReceiverPath" + get() = "$paymentRequestId|$counterparty|$counterpartyReceiverPath|${billingPeriod?.startsAt ?: ""}" private val previewRequest = PaykitPaymentRequest( paymentRequestId = "payment-request", @@ -583,10 +630,12 @@ private fun PaymentRequestsSheetPreview() { PaymentRequestsSheetContent( requests = persistentListOf(previewRequest), contacts = persistentListOf(), + subscriptions = persistentListOf(), onNotNow = {}, onSeeAll = {}, onPay = {}, - onReject = { Result.success(Unit) }, + onDismiss = { Result.success(Unit) }, + onDetails = {}, ) } } @@ -606,11 +655,13 @@ private fun PaymentRequestsPreview() { ), pending = persistentListOf(previewRequest), contacts = persistentListOf(), + subscriptions = persistentListOf(), canRequestPayment = true, onBack = {}, onRequestPayment = {}, onPay = {}, - onReject = { Result.success(Unit) }, + onDismiss = { Result.success(Unit) }, + onDetails = {}, ) } } 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 new file mode 100644 index 0000000000..04fd9b0f6d --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt @@ -0,0 +1,1029 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.ui.screens.subscriptions + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.rememberLottieComposition +import com.synonym.paykit.PaymentRequestLifecycleState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.ext.dateTimeFormatterOf +import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.models.safe +import to.bitkit.repositories.PaykitPaymentRequestId +import to.bitkit.repositories.PaykitRecurrenceUnit +import to.bitkit.repositories.PaykitSubscription +import to.bitkit.repositories.PaykitSubscriptionId +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.FillWidth +import to.bitkit.ui.components.MoneyCell +import to.bitkit.ui.components.MoneyDisplay +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.PubkyContactAvatar +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.Sheet +import to.bitkit.ui.components.SubscriptionRoute +import to.bitkit.ui.components.SwipeToConfirm +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.components.rememberMoneyText +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.screens.paymentrequests.PaymentRequestCard +import to.bitkit.ui.screens.paymentrequests.PaymentRequestsScreen +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.theme.Colors +import to.bitkit.ui.utils.removeAccentTags +import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.AppViewModel +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@Composable +fun SubscriptionsScreen( + appViewModel: AppViewModel, + onBack: () -> Unit, + onRequestPayment: () -> Unit, + onDetails: (PaykitSubscriptionId) -> Unit, + onPaymentRequestDetails: (PaykitPaymentRequestId) -> Unit, + showPayments: Boolean = false, +) { + val subscriptions by appViewModel.subscriptions.collectAsStateWithLifecycle() + val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() + val pendingPaymentRequests by appViewModel.pendingPaymentRequests.collectAsStateWithLifecycle() + val now = rememberSubscriptionNow(subscriptions) + + SubscriptionsContent( + subscriptions = subscriptions.toImmutableList(), + contacts = contacts.toImmutableList(), + acceptedAt = appViewModel::subscriptionAcceptedAt, + now = now, + onBack = onBack, + initialTab = if (showPayments) SubscriptionTab.Payments else SubscriptionTab.Overview, + pendingPaymentRequestCount = pendingPaymentRequests.size, + onSubscription = { subscription -> + if (subscription.isProposalVisible(now)) { + appViewModel.showSheet(Sheet.Subscription(SubscriptionRoute.Review(subscription.id))) + } else { + onDetails(subscription.id) + } + }, + paymentsContent = { + PaymentRequestsScreen( + appViewModel = appViewModel, + onBack = onBack, + onRequestPayment = onRequestPayment, + onDetails = onPaymentRequestDetails, + showsNavigationBar = false, + ) + }, + ) +} + +@Composable +internal fun SubscriptionsContent( + subscriptions: ImmutableList, + contacts: ImmutableList, + acceptedAt: (PaykitSubscriptionId) -> Instant?, + now: Instant, + onBack: () -> Unit, + initialTab: SubscriptionTab, + pendingPaymentRequestCount: Int, + onSubscription: (PaykitSubscription) -> Unit, + paymentsContent: @Composable () -> Unit, +) { + val proposals = subscriptions.filter { it.isProposalVisible(now) } + val active = subscriptions.filter { it.isActive(now) } + val expired = subscriptions.filter { it.isExpired(now) && acceptedAt(it.id) != null } + val hasVisibleSubscriptions = proposals.isNotEmpty() || active.isNotEmpty() || expired.isNotEmpty() + var selectedTabIndex by rememberSaveable { mutableIntStateOf(initialTab.ordinal) } + val selectedTab = SubscriptionTab.entries[selectedTabIndex] + + Column( + modifier = Modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + .testTag("SubscriptionsScreen") + ) { + AppTopBar( + titleText = stringResource(R.string.subscriptions__title), + onBackClick = onBack, + actions = { DrawerNavIcon() }, + ) + SubscriptionTabs( + selectedTab = selectedTab, + pendingPaymentRequestCount = pendingPaymentRequestCount, + onTabChange = { selectedTabIndex = it.ordinal }, + ) + + if (selectedTab == SubscriptionTab.Payments) { + Box(Modifier.weight(1f)) { + paymentsContent() + } + } else if (!hasVisibleSubscriptions) { + SubscriptionEmptyState(Modifier.weight(1f)) + } else { + LazyColumn( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 32.dp), + verticalArrangement = Arrangement.spacedBy(32.dp), + modifier = Modifier.weight(1f), + ) { + item { + SubscriptionMetrics( + dueSats = dueThisMonth( + subscriptions.filter { it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING }, + acceptedAt, + now, + ), + activeCount = active.size, + ) + } + subscriptionSection( + titleRes = R.string.subscriptions__proposals, + subscriptions = proposals, + contacts = contacts, + now = now, + onSubscription = onSubscription, + ) + subscriptionSection( + titleRes = R.string.subscriptions__active, + subscriptions = active, + contacts = contacts, + now = now, + onSubscription = onSubscription, + ) + subscriptionSection( + titleRes = R.string.subscriptions__expired, + subscriptions = expired, + contacts = contacts, + now = now, + onSubscription = onSubscription, + ) + } + } + } +} + +private fun androidx.compose.foundation.lazy.LazyListScope.subscriptionSection( + @androidx.annotation.StringRes titleRes: Int, + subscriptions: List, + contacts: ImmutableList, + now: Instant, + onSubscription: (PaykitSubscription) -> Unit, +) { + if (subscriptions.isEmpty()) return + item(key = "subscription-section-$titleRes") { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Caption13Up(text = stringResource(titleRes), color = Colors.White64) + subscriptions.forEach { subscription -> + SubscriptionRow( + subscription = subscription, + contact = contacts.contactFor(subscription), + now = now, + faded = subscription.isExpired(now), + onClick = { onSubscription(subscription) }, + ) + } + } + } +} + +@Composable +private fun SubscriptionTabs( + selectedTab: SubscriptionTab, + pendingPaymentRequestCount: Int, + onTabChange: (SubscriptionTab) -> Unit, +) { + CustomTabRowWithSpacing( + tabs = persistentListOf(SubscriptionTab.Overview, SubscriptionTab.Payments), + currentTabIndex = selectedTab.ordinal, + selectedColor = Colors.White, + onTabChange = onTabChange, + badgeCount = { tab -> pendingPaymentRequestCount.takeIf { tab == SubscriptionTab.Payments } }, + modifier = Modifier.padding(horizontal = 16.dp) + ) +} + +internal enum class SubscriptionTab : TabItem { + Overview, + Payments; + + override val uiText: String + @Composable get() = stringResource( + when (this) { + Overview -> R.string.subscriptions__overview + Payments -> R.string.subscriptions__payments + } + ) +} + +@Composable +private fun SubscriptionEmptyState(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 24.dp) + ) { + FillHeight() + Image( + painter = painterResource(R.drawable.subscription_clock), + contentDescription = null, + modifier = Modifier + .size(256.dp) + .align(Alignment.CenterHorizontally), + ) + FillHeight() + Display( + text = stringResource(R.string.subscriptions__empty_headline).withAccent(accentColor = Colors.Purple), + ) + VerticalSpacer(12.dp) + BodyM(text = stringResource(R.string.subscriptions__empty_description), color = Colors.White64) + } +} + +@Composable +private fun SubscriptionMetrics(dueSats: Long, activeCount: Int) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.weight(1f)) { + Caption13Up(text = stringResource(R.string.subscriptions__due_this_month), color = Colors.White64) + VerticalSpacer(8.dp) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(painterResource(R.drawable.ic_calendar), contentDescription = null, tint = Colors.Purple) + to.bitkit.ui.components.MoneyMSB(sats = dueSats) + } + } + Spacer(Modifier.size(width = 1.dp, height = 50.dp).background(Colors.White16)) + Column(modifier = Modifier.weight(1f).padding(start = 16.dp)) { + Caption13Up(text = stringResource(R.string.subscriptions__active), color = Colors.White64) + VerticalSpacer(8.dp) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(painterResource(R.drawable.ic_arrows_clockwise), contentDescription = null, tint = Colors.Purple) + BodyMSB(text = activeCount.toString()) + } + } + } +} + +@Composable +private fun SubscriptionRow( + subscription: PaykitSubscription, + contact: PubkyProfile, + now: Instant, + faded: Boolean, + onClick: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .alpha(if (faded) 0.5f else 1f) + .clip(RoundedCornerShape(16.dp)) + .background(Colors.Gray6) + .clickable(onClick = onClick) + .padding(16.dp), + ) { + PubkyContactAvatar(profile = contact, size = 40.dp) + Column(modifier = Modifier.padding(start = 16.dp).weight(1f)) { + BodyMSB( + text = subscription.note ?: stringResource(R.string.subscriptions__subscription), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BodyS( + text = subscription.rowSubtitle(now), + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + MoneyCell(sats = subscription.displaySats) + } +} + +@Composable +fun SubscriptionDetailScreen( + appViewModel: AppViewModel, + id: PaykitSubscriptionId, + onBack: () -> Unit, +) { + val subscriptions by appViewModel.subscriptions.collectAsStateWithLifecycle() + val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() + val paymentHistory by appViewModel.paymentRequestHistory.collectAsStateWithLifecycle() + val subscription = subscriptions.firstOrNull { it.id == id } + val now = rememberSubscriptionNow(listOfNotNull(subscription)) + + Column( + modifier = Modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + ) { + AppTopBar( + titleText = subscription?.note ?: stringResource(R.string.subscriptions__subscription), + onBackClick = onBack, + actions = { DrawerNavIcon() }, + ) + if (subscription == null) { + FillHeight() + BodyM( + text = stringResource(R.string.subscriptions__unavailable), + color = Colors.White64, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + FillHeight() + return@Column + } + + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(32.dp), + modifier = Modifier + .weight(1f) + .alpha(if (subscription.isExpired(now)) 0.5f else 1f), + ) { + item { + Column(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth()) { + Caption13Up(text = subscription.cadenceText(), color = Colors.White64) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + MoneyDisplay(sats = subscription.displaySats, showSymbol = true) + FillWidth() + PubkyContactAvatar(profile = contacts.contactFor(subscription), size = 48.dp) + } + } + } + item { SubscriptionDetailsGrid(subscription, now) } + val payments = paymentHistory.filter { it.belongsTo(subscription) } + if (payments.isNotEmpty()) { + item { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Caption13Up(text = stringResource(R.string.subscriptions__payments), color = Colors.White64) + payments.forEach { payment -> + PaymentRequestCard( + request = payment, + contact = contacts.contactFor(subscription), + compactSubtitle = subscription.note?.takeIf(String::isNotBlank) + ?: stringResource(R.string.subscriptions__subscription), + isOutgoingPayment = true, + ) + } + } + } + } + } + SubscriptionDetailFooter(subscription, appViewModel, now) + } +} + +@Composable +private fun SubscriptionDetailsGrid(subscription: PaykitSubscription, now: Instant) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + SubscriptionDetailCell( + stringResource(R.string.subscriptions__subscription), + subscription.note ?: stringResource(R.string.subscriptions__subscription), + R.drawable.ic_cube, + Modifier.weight(1f), + ) + SubscriptionDetailCell( + stringResource(R.string.subscriptions__frequency), + subscription.frequencyValue(), + R.drawable.ic_arrows_clockwise, + Modifier.weight(1f), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + SubscriptionDetailCell( + stringResource(R.string.subscriptions__status), + if (subscription.isActive(now)) { + stringResource(R.string.subscriptions__active) + } else { + stringResource(R.string.subscriptions__expired) + }, + R.drawable.ic_check, + Modifier.weight(1f), + ) + if (subscription.shouldShowTiming(now)) { + SubscriptionDetailCell( + subscription.timingTitle(now), + subscription.renewalText(now), + R.drawable.ic_calendar, + Modifier.weight(1f), + ) + } else { + Spacer(Modifier.weight(1f)) + } + } + } +} + +@Composable +private fun SubscriptionDetailCell( + title: String, + value: String, + @androidx.annotation.DrawableRes iconRes: Int, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .height(68.dp) + ) { + Caption13Up(text = title, color = Colors.White64) + VerticalSpacer(8.dp) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = Colors.Purple, + modifier = Modifier.size(16.dp), + ) + BodySSB(text = value, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + FillHeight() + HorizontalDivider(color = Colors.White10) + } +} + +@Composable +private fun SubscriptionDetailFooter( + subscription: PaykitSubscription, + appViewModel: AppViewModel, + now: Instant, +) { + val hasMoreInfo = subscription.metadata.description != null || subscription.metadata.benefits.isNotEmpty() + val canCancel = subscription.canCancel(now) + if (!hasMoreInfo && !canCancel) return + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), + ) { + if (hasMoreInfo) { + SecondaryButton( + text = stringResource(R.string.subscriptions__more_info), + onClick = { appViewModel.showSheet(Sheet.Subscription(SubscriptionRoute.Details(subscription.id))) }, + modifier = Modifier.weight(1f), + ) + } + if (canCancel) { + PrimaryButton( + text = stringResource(R.string.subscriptions__cancel), + onClick = { appViewModel.showSheet(Sheet.Subscription(SubscriptionRoute.Cancel(subscription.id))) }, + modifier = Modifier.weight(1f), + icon = { + Icon( + painter = painterResource(R.drawable.ic_x), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + ) + } + } +} + +@Composable +fun SubscriptionSheet(appViewModel: AppViewModel, initialRoute: SubscriptionRoute) { + var route by remember(initialRoute) { mutableStateOf(initialRoute) } + var previousRoute by remember(initialRoute) { mutableStateOf(null) } + var isProcessing by remember(initialRoute) { mutableStateOf(false) } + val subscriptions by appViewModel.subscriptions.collectAsStateWithLifecycle() + val subscription = subscriptions.firstOrNull { it.id == route.id } + val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() + val now = rememberSubscriptionNow(listOfNotNull(subscription)) + + LaunchedEffect(route, subscription, isProcessing) { + val proposalIsUnavailable = route is SubscriptionRoute.Review && + subscription?.isProposalVisible(now) != true + if (!isProcessing && proposalIsUnavailable) { + appViewModel.hideSheet() + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .sheetHeight() + .gradientBackground() + ) { + if (subscription == null) { + Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) { + SheetTopBar(titleText = stringResource(R.string.subscriptions__subscription)) + FillHeight() + BodyM(stringResource(R.string.subscriptions__unavailable), color = Colors.White64) + FillHeight() + PrimaryButton(text = stringResource(R.string.common__close), onClick = appViewModel::hideSheet) + VerticalSpacer(16.dp) + } + } else { + val payOnAcceptance = subscription.paymentDueOnAcceptance(now) != null + when (route) { + is SubscriptionRoute.Review -> SubscriptionReview( + subscription = subscription, + payOnAcceptance = payOnAcceptance, + now = now, + contact = contacts.contactFor(subscription), + onDetails = { + previousRoute = route + route = SubscriptionRoute.Details(subscription.id) + }, + onSubscribe = { + isProcessing = true + appViewModel.acceptSubscriptionAndStartPayment(subscription).fold( + onSuccess = { startedPayment -> + if (startedPayment) { + true + } else { + route = SubscriptionRoute.Success(subscription.id) + isProcessing = false + true + } + }, + onFailure = { + isProcessing = false + false + }, + ) + }, + ) + is SubscriptionRoute.Success -> SubscriptionSuccess(onClose = appViewModel::hideSheet) + is SubscriptionRoute.Details -> SubscriptionMoreInfo( + subscription = subscription, + contact = contacts.contactFor(subscription), + onBack = { + if (previousRoute != null) { + route = requireNotNull(previousRoute) + previousRoute = null + } else { + appViewModel.hideSheet() + } + }, + onClose = appViewModel::hideSheet, + ) + is SubscriptionRoute.Cancel -> SubscriptionCancel( + subscription = subscription, + contact = contacts.contactFor(subscription), + onDetails = { + previousRoute = route + route = SubscriptionRoute.Details(subscription.id) + }, + onCancel = { + appViewModel.cancelSubscription(subscription.id) + .onSuccess { appViewModel.hideSheet() } + .isSuccess + }, + ) + } + } + } +} + +private val SubscriptionRoute.id: PaykitSubscriptionId + get() = when (this) { + is SubscriptionRoute.Review -> id + is SubscriptionRoute.Success -> id + is SubscriptionRoute.Details -> id + is SubscriptionRoute.Cancel -> id + } + +@Composable +private fun SubscriptionReview( + subscription: PaykitSubscription, + contact: PubkyProfile, + payOnAcceptance: Boolean, + now: Instant, + onDetails: () -> Unit, + onSubscribe: suspend () -> Boolean, +) { + var loading by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + Column( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + ) { + SheetTopBar(titleText = stringResource(R.string.subscriptions__review_and_subscribe)) + rememberMoneyText(sats = subscription.displaySats, reversed = true, showSymbol = true)?.let { + Caption13Up(text = it.removeAccentTags(), color = Colors.White64) + } + MoneyDisplay(sats = subscription.displaySats, showSymbol = true) + VerticalSpacer(24.dp) + SubscriptionProviderCard(subscription, contact, onClick = onDetails) + if (!subscription.recurrence.unit.isSupported) { + VerticalSpacer(16.dp) + BodyM(text = stringResource(R.string.subscriptions__unsupported_description), color = Colors.White64) + } else if (subscription.acceptedPaymentEndpointIdentifiers.isEmpty()) { + VerticalSpacer(16.dp) + BodyM( + text = stringResource(R.string.subscriptions__unsupported_payment_description), + color = Colors.White64, + ) + } + FillHeight() + Image( + painter = painterResource(R.drawable.subscription_clock), + contentDescription = null, + modifier = Modifier.size(256.dp).align(Alignment.CenterHorizontally), + ) + FillHeight() + if (subscription.isProposalActionable(now)) { + SwipeToConfirm( + text = stringResource( + if (payOnAcceptance) { + R.string.subscriptions__swipe_to_subscribe_and_pay + } else { + R.string.subscriptions__swipe_to_subscribe + } + ), + color = Colors.Purple, + loading = loading, + onConfirm = { + loading = true + scope.launch { + if (!onSubscribe()) loading = false + } + }, + ) + } + VerticalSpacer(16.dp) + } +} + +@Composable +private fun SubscriptionProviderCard( + subscription: PaykitSubscription, + contact: PubkyProfile, + subtitle: String? = null, + onClick: (() -> Unit)? = null, +) { + val displayedSubtitle = subtitle ?: subscription.subscriptionFrequencyText() + val cardModifier = if (onClick == null) { + Modifier.fillMaxWidth() + } else { + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(Colors.Gray6) + .clickable(onClick = onClick) + .padding(16.dp) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = cardModifier, + ) { + PubkyContactAvatar(profile = contact, size = 40.dp) + Column(Modifier.padding(start = 16.dp).weight(1f)) { + BodyMSB( + text = subscription.note ?: stringResource(R.string.subscriptions__subscription), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BodyS( + text = displayedSubtitle, + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (onClick != null) { + Icon(painterResource(R.drawable.ic_chevron_right), contentDescription = null, tint = Colors.White64) + } + } +} + +@Composable +fun SubscriptionSuccess( + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxSize() + .navigationBarsPadding() + ) { + val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.confetti_purple)) + LottieAnimation( + composition = composition, + contentScale = ContentScale.Crop, + iterations = 100, + modifier = Modifier.fillMaxSize(), + ) + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + SheetTopBar(titleText = stringResource(R.string.subscriptions__subscribed)) + FillHeight() + Image( + painter = painterResource(R.drawable.check), + contentDescription = null, + modifier = Modifier.size(256.dp).align(Alignment.CenterHorizontally), + ) + FillHeight() + PrimaryButton(text = stringResource(R.string.common__close), onClick = onClose) + VerticalSpacer(16.dp) + } + } +} + +@Composable +private fun SubscriptionMoreInfo( + subscription: PaykitSubscription, + contact: PubkyProfile, + onBack: () -> Unit, + onClose: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + ) { + SheetTopBar(titleText = stringResource(R.string.subscriptions__details), onBack = onBack) + SubscriptionProviderCard(subscription, contact) + VerticalSpacer(24.dp) + LazyColumn(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.weight(1f)) { + subscription.metadata.description?.let { item { BodySSB(it) } } + items(subscription.metadata.benefits) { benefit -> + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + BodySSB("•") + BodySSB(benefit) + } + } + } + PrimaryButton(text = stringResource(R.string.common__ok), onClick = onClose) + VerticalSpacer(16.dp) + } +} + +@Composable +private fun SubscriptionCancel( + subscription: PaykitSubscription, + contact: PubkyProfile, + onDetails: () -> Unit, + onCancel: suspend () -> Boolean, +) { + var loading by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + Column( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + ) { + SheetTopBar(titleText = stringResource(R.string.subscriptions__cancel_subscription)) + rememberMoneyText(sats = subscription.displaySats, reversed = true, showSymbol = true)?.let { + Caption13Up(text = it.removeAccentTags(), color = Colors.White64) + } + MoneyDisplay(sats = subscription.displaySats, showSymbol = true) + VerticalSpacer(24.dp) + SubscriptionProviderCard( + subscription = subscription, + contact = contact, + subtitle = subscription.rowSubtitle(Clock.System.now()), + onClick = onDetails, + ) + FillHeight() + Image( + painter = painterResource(R.drawable.cross), + contentDescription = null, + modifier = Modifier + .size(256.dp) + .align(Alignment.CenterHorizontally), + ) + FillHeight() + SwipeToConfirm( + text = stringResource(R.string.subscriptions__swipe_to_cancel), + color = Colors.Red, + loading = loading, + onConfirm = { + loading = true + scope.launch { + if (!onCancel()) loading = false + } + }, + ) + VerticalSpacer(16.dp) + } +} + +private fun List.contactFor(subscription: PaykitSubscription): PubkyProfile = + firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, subscription.counterparty) } + ?: PubkyProfile.placeholder(subscription.counterparty) + +@Composable +private fun PaykitSubscription.cadenceText(): String = when (recurrence.unit) { + PaykitRecurrenceUnit.Day -> if (recurrence.every == 1) { + stringResource(R.string.subscriptions__per_day) + } else { + stringResource(R.string.subscriptions__every_days, recurrence.every) + } + PaykitRecurrenceUnit.Week -> if (recurrence.every == 1) { + stringResource(R.string.subscriptions__per_week) + } else { + stringResource(R.string.subscriptions__every_weeks, recurrence.every) + } + PaykitRecurrenceUnit.Month -> if (recurrence.every == 1) { + stringResource(R.string.subscriptions__per_month) + } else { + stringResource(R.string.subscriptions__every_months, recurrence.every) + } + PaykitRecurrenceUnit.Year -> if (recurrence.every == 1) { + stringResource(R.string.subscriptions__per_year) + } else { + stringResource(R.string.subscriptions__every_years, recurrence.every) + } + PaykitRecurrenceUnit.Minute, PaykitRecurrenceUnit.Hour -> + stringResource(R.string.subscriptions__unsupported_frequency) +} + +@Composable +private fun PaykitSubscription.frequencyValue(): String { + if (recurrence.every != 1) return cadenceText() + return when (recurrence.unit) { + PaykitRecurrenceUnit.Day -> stringResource(R.string.subscriptions__daily) + PaykitRecurrenceUnit.Week -> stringResource(R.string.subscriptions__weekly) + PaykitRecurrenceUnit.Month -> stringResource(R.string.subscriptions__monthly) + PaykitRecurrenceUnit.Year -> stringResource(R.string.subscriptions__yearly) + PaykitRecurrenceUnit.Minute, PaykitRecurrenceUnit.Hour -> + stringResource(R.string.subscriptions__unsupported_frequency) + } +} + +@Composable +private fun PaykitSubscription.subscriptionFrequencyText(): String { + if (recurrence.every != 1) return cadenceText() + return when (recurrence.unit) { + PaykitRecurrenceUnit.Day -> stringResource(R.string.subscriptions__daily_subscription) + PaykitRecurrenceUnit.Week -> stringResource(R.string.subscriptions__weekly_subscription) + PaykitRecurrenceUnit.Month -> stringResource(R.string.subscriptions__monthly_subscription) + PaykitRecurrenceUnit.Year -> stringResource(R.string.subscriptions__yearly_subscription) + PaykitRecurrenceUnit.Minute, PaykitRecurrenceUnit.Hour -> + stringResource(R.string.subscriptions__unsupported_frequency) + } +} + +@Composable +private fun PaykitSubscription.rowSubtitle(now: Instant): String = when { + isProposalVisible(now) || !recurrence.unit.isSupported -> subscriptionFrequencyText() + isExpired(now) -> recurrence.endsAt?.let { + stringResource(R.string.subscriptions__expires_date, it.formatShortDate()) + } ?: stringResource(R.string.subscriptions__expired) + recurrence.endsAt != null -> stringResource( + R.string.subscriptions__expires_date, + recurrence.endsAt.formatShortDate(), + ) + else -> { + val renewal = recurrence.nextPeriodAfter(now)?.startsAt + if (renewal == null) { + subscriptionFrequencyText() + } else { + stringResource( + R.string.subscriptions__renews_date, + renewal.formatShortDate(), + ) + } + } +} + +internal fun PaykitSubscription.shouldShowTiming(now: Instant): Boolean = + isActive(now) || recurrence.endsAt != null + +internal fun PaykitSubscription.canCancel(now: Instant): Boolean = + isActive(now) && recurrence.endsAt == null + +@Composable +private fun PaykitSubscription.timingTitle(now: Instant): String = when { + !isActive(now) -> stringResource(R.string.subscriptions__expired) + recurrence.endsAt == null -> stringResource(R.string.subscriptions__renews) + else -> stringResource(R.string.subscriptions__expires) +} + +@Composable +private fun PaykitSubscription.renewalText(now: Instant): String = + (recurrence.endsAt ?: recurrence.nextPeriodAfter(now)?.startsAt)?.formatFullDate() + ?: stringResource(R.string.subscriptions__ongoing) + +@Composable +private fun rememberSubscriptionNow(subscriptions: List): Instant { + var now by remember(subscriptions) { mutableStateOf(Clock.System.now()) } + LaunchedEffect(subscriptions, now) { + val nextTransition = nextSubscriptionTransition(subscriptions, now) ?: return@LaunchedEffect + delay(nextTransition - now) + now = Clock.System.now() + } + return now +} + +internal fun nextSubscriptionTransition( + subscriptions: List, + now: Instant, + zoneId: java.time.ZoneId = java.time.ZoneId.systemDefault(), +): Instant? { + val activeSubscriptions = subscriptions.filter { it.isActive(now) } + val dates = subscriptions.flatMap { + listOf(it.recurrence.startsAt, it.proposalExpiresAt, it.recurrence.endsAt) + }.filterNotNull().toMutableList() + dates += activeSubscriptions.mapNotNull { it.recurrence.nextPeriodAfter(now)?.startsAt } + if (activeSubscriptions.isNotEmpty()) { + val nextMonth = java.time.Instant.ofEpochMilli(now.toEpochMilliseconds()) + .atZone(zoneId) + .toLocalDate() + .withDayOfMonth(1) + .plusMonths(1) + .atStartOfDay(zoneId) + .toInstant() + dates += Instant.fromEpochMilliseconds(nextMonth.toEpochMilli()) + } + return dates.filter { it > now }.minOrNull() +} + +private fun Instant.formatShortDate(): String = dateTimeFormatterOf("MMMM d") + .format(java.time.Instant.ofEpochMilli(toEpochMilliseconds())) + +private fun Instant.formatFullDate(): String = dateTimeFormatterOf("MMMM d, yyyy") + .format(java.time.Instant.ofEpochMilli(toEpochMilliseconds())) + +private val PaykitSubscription.displaySats: Long + get() = amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong() + +private fun dueThisMonth( + subscriptions: List, + acceptedAt: (PaykitSubscriptionId) -> Instant?, + now: Instant, +): Long { + val zonedNow = java.time.Instant.ofEpochMilli(now.toEpochMilliseconds()).atZone(java.time.ZoneId.systemDefault()) + val start = zonedNow.withDayOfMonth(1).toLocalDate().atStartOfDay(zonedNow.zone).toInstant() + val end = zonedNow.plusMonths(1).withDayOfMonth(1).toLocalDate().atStartOfDay(zonedNow.zone).toInstant() + val startInstant = Instant.fromEpochMilliseconds(start.toEpochMilli()) + val endInstant = Instant.fromEpochMilliseconds(end.toEpochMilli()) + val total = subscriptions.fold(0uL) { total, subscription -> + val acceptance = acceptedAt(subscription.id) ?: return@fold total + val count = subscription.recurrence.periodsThrough(endInstant, acceptance).count { + it.startsAt >= startInstant && it.startsAt < endInstant && it !in subscription.paidPeriods + } + val subtotal = subscription.amountSats.safe() * count.toULong().safe() + total.safe() + subtotal.safe() + } + return total.coerceAtMost(Long.MAX_VALUE.toULong()).toLong() +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/CustomTabRowWithSpacing.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/CustomTabRowWithSpacing.kt index 4c7e2fccfe..4a03f80e3d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/CustomTabRowWithSpacing.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/CustomTabRowWithSpacing.kt @@ -12,7 +12,9 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -33,6 +35,7 @@ fun CustomTabRowWithSpacing( onTabChange: (T) -> Unit, modifier: Modifier = Modifier, selectedColor: Color = Colors.Brand, + badgeCount: (T) -> Int? = { null }, ) { Column(modifier = modifier) { Row( @@ -54,12 +57,27 @@ fun CustomTabRowWithSpacing( .padding(vertical = 8.dp) .testTag("Tab-${tab.name.lowercase()}") ) { - CaptionB( - tab.uiText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (isSelected) Colors.White else Colors.White50 - ) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CaptionB( + tab.uiText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (isSelected) Colors.White else Colors.White50 + ) + badgeCount(tab)?.takeIf { it > 0 }?.let { count -> + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(20.dp) + .background(Colors.Brand, CircleShape), + ) { + CaptionB(text = count.toString(), color = Colors.White) + } + } + } } val animatedColor by animateColorAsState( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index 289a6f2587..88f7b569e8 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -93,6 +94,8 @@ fun ReceiveQrScreen( onClickReceiveCjit: () -> Unit, modifier: Modifier = Modifier, initialTab: ReceiveTab? = null, + showPaymentRequestContacts: Boolean = false, + onClickPaymentRequestContacts: () -> Unit = {}, ) { SetMaxBrightness() @@ -197,7 +200,26 @@ fun ReceiveQrScreen( .navigationBarsPadding() .keepScreenOn() ) { - SheetTopBar(stringResource(R.string.wallet__receive_bitcoin)) + SheetTopBar( + titleText = stringResource(R.string.wallet__receive_bitcoin), + action = if (showPaymentRequestContacts) { + { + IconButton( + onClick = onClickPaymentRequestContacts, + modifier = Modifier.testTag("ReceivePaymentRequestContacts"), + ) { + Icon( + painter = painterResource(R.drawable.ic_users), + contentDescription = stringResource(R.string.wallet__payment_request_choose_recipient), + tint = Colors.White, + modifier = Modifier.size(24.dp), + ) + } + } + } else { + null + }, + ) Column { VerticalSpacer(16.dp) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index dc7587a4c7..c2dbb5b3e1 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt @@ -22,15 +22,19 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute import kotlinx.serialization.Serializable import to.bitkit.R +import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.repositories.LightningState import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitPaymentRequestDraft +import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.repositories.WalletState import to.bitkit.ui.components.ConnectionIssuesView import to.bitkit.ui.navigateTo import to.bitkit.ui.openNotificationSettings +import to.bitkit.ui.screens.paymentrequests.PaymentRequestAmountScreen import to.bitkit.ui.screens.paymentrequests.PaymentRequestDetailsScreen import to.bitkit.ui.screens.paymentrequests.PaymentRequestRecipientScreen import to.bitkit.ui.screens.paymentrequests.PaymentRequestSentScreen @@ -48,6 +52,7 @@ import kotlin.time.Duration.Companion.days import kotlin.time.ExperimentalTime @OptIn(ExperimentalTime::class) +@Suppress("CyclomaticComplexMethod") @Composable fun ReceiveSheet( appViewModel: AppViewModel, @@ -69,6 +74,7 @@ fun ReceiveSheet( val cjitEntryDetails = remember { mutableStateOf(null) } val lightningState: LightningState by wallet.lightningState.collectAsStateWithLifecycle() val paymentRequestTargets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() + val paymentRequestContacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() var paymentRequestDraft by remember { mutableStateOf( PaykitPaymentRequestDraft( @@ -79,6 +85,17 @@ fun ReceiveSheet( ) } var createdPaymentRequest by remember { mutableStateOf(null) } + var selectedPaymentRequestTarget by remember(startRoute) { + mutableStateOf( + (startRoute as? ReceiveRoute.PaymentRequestAmount)?.let { + val publicKey = it.publicKey ?: return@let null + val receiverPath = it.receiverPath ?: return@let null + PaykitPaymentRequestTarget(publicKey, receiverPath) + } + ) + } + var skipPaymentRequestAmount by remember { mutableStateOf(false) } + var isEditingPaymentRequestAmount by remember { mutableStateOf(false) } LaunchedEffect(Unit) { wallet.resetPreActivityMetadataTagsForCurrentInvoice() @@ -118,43 +135,91 @@ fun ReceiveSheet( } }, onClickEditInvoice = { navController.navigateTo(ReceiveRoute.EditInvoice) }, - ) - } - composableWithDefaultTransitions { - PaymentRequestDetailsScreen( - amountInputViewModel = paymentRequestAmountViewModel, - initialDraft = paymentRequestDraft, - onBack = { navController.popBackStack() }, - onContinue = { - paymentRequestDraft = it + showPaymentRequestContacts = paymentRequestTargets.isNotEmpty(), + onClickPaymentRequestContacts = { + paymentRequestDraft = paymentRequestDraft.copy( + amountSats = 0uL, + note = "", + expiresAt = Clock.System.now() + 7.days, + ) + selectedPaymentRequestTarget = null + skipPaymentRequestAmount = false + isEditingPaymentRequestAmount = false navController.navigateTo(ReceiveRoute.PaymentRequestRecipient) }, ) } - composableWithDefaultTransitions { - PaymentRequestDetailsScreen( + composableWithDefaultTransitions { backStackEntry -> + val route = backStackEntry.toRoute() + val routeTarget = route.publicKey?.let { publicKey -> + route.receiverPath?.let { receiverPath -> PaykitPaymentRequestTarget(publicKey, receiverPath) } + } + val contact = (routeTarget ?: selectedPaymentRequestTarget)?.let { target -> + paymentRequestContacts.firstOrNull { + PubkyPublicKeyFormat.matches(it.publicKey, target.publicKey) + } + } + PaymentRequestAmountScreen( amountInputViewModel = paymentRequestAmountViewModel, initialDraft = paymentRequestDraft, - onBack = { navController.popBackStack() }, + contact = contact, + onBack = { + isEditingPaymentRequestAmount = false + if (!navController.popBackStack()) appViewModel.hideSheet() + }, onContinue = { paymentRequestDraft = it - navController.popBackStack() + if (isEditingPaymentRequestAmount) { + isEditingPaymentRequestAmount = false + navController.popBackStack() + } else { + navController.navigateTo(ReceiveRoute.PaymentRequestDetails) + } }, ) } composableWithDefaultTransitions { PaymentRequestRecipientScreen( appViewModel = appViewModel, - draft = paymentRequestDraft, - onEditExpiration = { - navController.navigateTo(ReceiveRoute.PaymentRequestExpiration) + onBack = { + if (!navController.popBackStack()) appViewModel.hideSheet() }, - onSent = { - createdPaymentRequest = it - navController.navigateTo(ReceiveRoute.PaymentRequestSent) + onSelected = { target -> + selectedPaymentRequestTarget = target + navController.navigateTo( + if (skipPaymentRequestAmount) { + ReceiveRoute.PaymentRequestDetails + } else { + ReceiveRoute.PaymentRequestAmount() + } + ) }, ) } + composableWithDefaultTransitions { + val target = selectedPaymentRequestTarget + if (target != null) { + PaymentRequestDetailsScreen( + appViewModel = appViewModel, + draft = paymentRequestDraft, + target = target, + onBack = { navController.popBackStack() }, + onEditAmount = { + paymentRequestDraft = it + isEditingPaymentRequestAmount = true + navController.navigateTo(ReceiveRoute.PaymentRequestAmount()) + }, + onSent = { + createdPaymentRequest = it + navController.navigateTo(ReceiveRoute.PaymentRequestSent) + }, + ) + } else { + LaunchedEffect(Unit) { + if (!navController.popBackStack()) appViewModel.hideSheet() + } + } + } composableWithDefaultTransitions { createdPaymentRequest?.let { PaymentRequestSentScreen( @@ -267,6 +332,9 @@ fun ReceiveSheet( note = note, expiresAt = Clock.System.now() + 7.days, ) + selectedPaymentRequestTarget = null + skipPaymentRequestAmount = true + isEditingPaymentRequestAmount = false navController.navigateTo(ReceiveRoute.PaymentRequestRecipient) }, navigateReceiveConfirm = { entry -> @@ -331,13 +399,16 @@ sealed interface ReceiveRoute { data object AddTag : DeepLinkStart @Serializable - data object PaymentRequestDetails : InternalOnly + data object PaymentRequestRecipient : InternalOnly @Serializable - data object PaymentRequestExpiration : InternalOnly + data class PaymentRequestAmount( + val publicKey: String? = null, + val receiverPath: String? = null, + ) : InternalOnly @Serializable - data object PaymentRequestRecipient : InternalOnly + data object PaymentRequestDetails : InternalOnly @Serializable data object PaymentRequestSent : InternalOnly 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 5d0929bdce..16a9d517ba 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 @@ -34,11 +34,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.graphics.PathEffect -import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag @@ -65,6 +60,7 @@ import to.bitkit.ext.formatInvoiceExpiryRelative import to.bitkit.models.FeeRate import to.bitkit.models.PubkyProfile import to.bitkit.models.TransactionSpeed +import to.bitkit.ui.components.AddTagButton import to.bitkit.ui.components.BalanceHeaderView import to.bitkit.ui.components.BiometricsView import to.bitkit.ui.components.BodySSB @@ -72,6 +68,7 @@ import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.GradientCircularProgressIndicator import to.bitkit.ui.components.NumberPadActionButton import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.PubkyContactAvatar @@ -87,7 +84,6 @@ import to.bitkit.ui.settingsViewModel import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground -import to.bitkit.ui.theme.AppShapes import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.rememberBiometricAuthSupported @@ -110,6 +106,7 @@ fun SendConfirmScreen( savedStateHandle: SavedStateHandle, uiState: SendUiState, isNodeRunning: Boolean, + canAutoStart: Boolean, canGoBack: Boolean, onBack: () -> Unit, onEvent: (SendEvent) -> Unit, @@ -135,6 +132,9 @@ fun SendConfirmScreen( .collect { isSuccess -> isLoading = isSuccess savedStateHandle.remove(PIN_CHECK_RESULT_KEY) + if (!isSuccess && uiState.isInitialSubscriptionPayment) { + currentOnEvent(SendEvent.CancelInitialSubscriptionPayment) + } } } @@ -153,6 +153,12 @@ fun SendConfirmScreen( } } + LaunchedEffect(uiState.initialSubscriptionPaymentAutoStartPending, canAutoStart) { + if (!uiState.initialSubscriptionPaymentAutoStartPending || !canAutoStart) return@LaunchedEffect + isLoading = true + currentOnEvent(SendEvent.StartInitialSubscriptionPayment) + } + Content( uiState = uiState, isNodeRunning = isNodeRunning, @@ -211,6 +217,8 @@ private fun Content( SendContactTopBar( titleText = when { + uiState.isInitialSubscriptionPayment -> stringResource(R.string.subscriptions__review_and_subscribe) + uiState.isSubscriptionPayment -> stringResource(R.string.subscriptions__subscription) uiState.isPaymentRequest -> stringResource(R.string.wallet__payment_request) isLnurlPay -> stringResource(R.string.wallet__lnurl_p_title) else -> stringResource(R.string.wallet__send_review) @@ -221,7 +229,11 @@ private fun Content( Spacer(Modifier.height(16.dp)) - if (isNodeRunning) { + if (uiState.isInitialSubscriptionPayment) { + FillHeight() + GradientCircularProgressIndicator(modifier = Modifier.size(32.dp).align(Alignment.CenterHorizontally)) + FillHeight() + } else if (isNodeRunning) { ContentRunning( uiState = uiState, isLoading = isLoading, @@ -257,7 +269,11 @@ private fun Content( onConfirm = { onEvent(SendEvent.ConfirmAmountWarning(dialog)) }, onDismiss = { onEvent(SendEvent.DismissAmountWarning) - onBack() + if (uiState.isInitialSubscriptionPayment) { + onEvent(SendEvent.CancelInitialSubscriptionPayment) + } else { + onBack() + } }, modifier = Modifier .semantics { testTagsAsResourceId = true } @@ -377,7 +393,13 @@ private fun ContentRunning( } SwipeToConfirm( - text = stringResource(R.string.wallet__send_swipe), + text = stringResource( + if (uiState.isInitialSubscriptionPayment) { + R.string.subscriptions__swipe_to_subscribe_and_pay + } else { + R.string.wallet__send_swipe + } + ), color = accentColor, loading = isLoading, confirmed = isLoading, @@ -439,44 +461,6 @@ private fun TagsSection( } } -@Composable -private fun AddTagButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val shape = AppShapes.small - val cornerRadius = 8.dp - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = modifier - .clip(shape) - .drawBehind { - drawRoundRect( - color = Colors.White64, - style = Stroke( - width = 1.dp.toPx(), - pathEffect = PathEffect.dashPathEffect(floatArrayOf(4f, 4f)), - ), - cornerRadius = CornerRadius(cornerRadius.toPx()), - ) - } - .clickableAlpha(onClick = onClick) - .padding(horizontal = 12.dp, vertical = 8.dp) - ) { - BodySSB( - text = stringResource(R.string.wallet__tags_add_button), - color = Colors.White, - ) - Icon( - painter = painterResource(R.drawable.ic_plus), - contentDescription = null, - tint = Colors.White64, - modifier = Modifier.size(16.dp) - ) - } -} - @Composable private fun OnChainDetails( uiState: SendUiState, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendErrorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendErrorScreen.kt index f574bfbd4b..1032cea971 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendErrorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendErrorScreen.kt @@ -32,6 +32,8 @@ fun SendErrorScreen( title: String, message: String?, isRetrying: Boolean, + retryText: String? = null, + secondaryText: String? = null, onRetry: () -> Unit, onContactSupport: () -> Unit, ) { @@ -39,6 +41,8 @@ fun SendErrorScreen( title = title, message, isRetrying = isRetrying, + retryText = retryText, + secondaryText = secondaryText, onRetry = onRetry, onContactSupport = onContactSupport, ) @@ -50,6 +54,8 @@ private fun Content( message: String?, modifier: Modifier = Modifier, isRetrying: Boolean = false, + retryText: String? = null, + secondaryText: String? = null, onRetry: () -> Unit = {}, onContactSupport: () -> Unit = {}, ) { @@ -84,7 +90,7 @@ private fun Content( FillHeight() SecondaryButton( - text = stringResource(R.string.wallet__send_error_support), + text = secondaryText ?: stringResource(R.string.wallet__send_error_support), onClick = onContactSupport, enabled = !isRetrying, modifier = Modifier @@ -95,7 +101,7 @@ private fun Content( VerticalSpacer(16.dp) PrimaryButton( - text = stringResource(R.string.common__try_again), + text = retryText ?: stringResource(R.string.common__try_again), onClick = onRetry, isLoading = isRetrying, modifier = Modifier diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt index 24b631d3ec..acc17b014f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt @@ -45,6 +45,7 @@ import to.bitkit.ui.theme.Colors fun SendPendingScreen( paymentHash: String, amount: Long, + observeResolution: Boolean = true, onPaymentSuccess: (String) -> Unit, onPaymentError: (PendingPaymentResolution.Failure) -> Unit, onClose: () -> Unit, @@ -53,9 +54,11 @@ fun SendPendingScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { viewModel.init(paymentHash, amount) } + if (observeResolution) { + LaunchedEffect(Unit) { viewModel.init(paymentHash, amount) } + } - uiState.resolution?.let { resolution -> + uiState.resolution?.takeIf { observeResolution }?.let { resolution -> LaunchedEffect(resolution) { when (resolution) { is PendingPaymentResolution.Success -> onPaymentSuccess(resolution.paymentHash) @@ -66,7 +69,7 @@ fun SendPendingScreen( } Content( - amount = uiState.amount, + amount = if (observeResolution) uiState.amount else amount, activityId = uiState.activityId, onClose = onClose, onViewDetails = onViewDetails, 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 c5d3c95d05..358c9557b3 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -41,6 +41,7 @@ import to.bitkit.ui.components.ConnectionIssuesView import to.bitkit.ui.components.SyncNodeView import to.bitkit.ui.navigateTo import to.bitkit.ui.screens.scanner.QrScanningScreen +import to.bitkit.ui.screens.subscriptions.SubscriptionSuccess import to.bitkit.ui.screens.wallets.send.AddTagScreen import to.bitkit.ui.screens.wallets.send.PIN_CHECK_RESULT_KEY import to.bitkit.ui.screens.wallets.send.SendAddressScreen @@ -141,7 +142,7 @@ fun SendSheet( is SendEffect.NavigateToComingSoon -> navController.navigateTo(SendRoute.ComingSoon) is SendEffect.NavigateToContacts -> navController.navigateTo(SendRoute.ContactSelect) is SendEffect.NavigateToPending -> navController.navigateTo( - SendRoute.Pending(it.paymentHash, it.amount) + SendRoute.Pending(it.paymentHash, it.amount, observeResolution = it.observeResolution) ) { popUpTo(startDestination) { inclusive = true } } is SendEffect.NavigateToError -> navController.navigateTo( SendRoute.errorFromFailure( @@ -249,6 +250,7 @@ fun SendSheet( savedStateHandle = it.savedStateHandle, uiState = uiState, isNodeRunning = lightningState.nodeLifecycleState.isRunning(), + canAutoStart = !isOffline && !shouldShowSyncOverlay, canGoBack = startDestination != SendRoute.Confirm, onBack = { val didPopToAmount = navController.popBackStack(SendRoute.Amount, inclusive = false) @@ -263,17 +265,25 @@ fun SendSheet( ) } composableWithDefaultTransitions { + val sendUiState by appViewModel.sendUiState.collectAsStateWithLifecycle() val sendDetail by appViewModel.successSendUiState.collectAsStateWithLifecycle() - NewTransactionSheetView( - details = sendDetail, - onCloseClick = { appViewModel.hideSheet() }, - onDetailClick = { appViewModel.onClickSendDetail() }, - modifier = Modifier - .fillMaxSize() - .gradientBackground() - .navigationBarsPadding() - .testTag("SendSuccess") - ) + if (sendUiState.isInitialSubscriptionPayment) { + SubscriptionSuccess( + onClose = appViewModel::hideSheet, + modifier = Modifier.gradientBackground(), + ) + } else { + NewTransactionSheetView( + details = sendDetail, + onCloseClick = { appViewModel.hideSheet() }, + onDetailClick = { appViewModel.onClickSendDetail() }, + modifier = Modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + .testTag("SendSuccess") + ) + } } composableWithDefaultTransitions { val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() @@ -368,6 +378,7 @@ fun SendSheet( SendPendingScreen( paymentHash = route.paymentHash, amount = route.amount, + observeResolution = route.observeResolution, onPaymentSuccess = { paymentHash -> appViewModel.onSendSuccess( NewTransactionSheetDetails( @@ -408,10 +419,26 @@ fun SendSheet( val isRetrying by walletViewModel.isRetryingLightningPayment.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() SendErrorScreen( - title = stringResource(route.failureTitle(sendUiState.payMethod)), - message = route.message, + title = if (sendUiState.isInitialSubscriptionPayment) { + stringResource(R.string.subscriptions__first_payment_failed) + } else { + stringResource(route.failureTitle(sendUiState.payMethod)) + }, + message = if (sendUiState.isInitialSubscriptionPayment) { + stringResource(R.string.subscriptions__first_payment_failed_description) + } else { + route.message + }, isRetrying = isRetrying, + retryText = stringResource(R.string.subscriptions__retry_payment) + .takeIf { sendUiState.isInitialSubscriptionPayment }, + secondaryText = stringResource(R.string.wallet__payment_requests_not_now) + .takeIf { sendUiState.isInitialSubscriptionPayment }, onRetry = { + sendUiState.incomingPaymentRequestId?.let { + appViewModel.retryIncomingPaymentRequest(it) + return@SendErrorScreen + } if (isRetrying) return@SendErrorScreen scope.launch { val shouldResetRoutingCaches = route.shouldResetRoutingCaches( @@ -435,12 +462,16 @@ fun SendSheet( } }, onContactSupport = { - appViewModel.navigateToReportIssue( - route.supportMessage( - paymentMethod = route.supportPaymentMethod(sendUiState.payMethod), - routingCacheResetAttempted = routingCacheResetAttempted, + if (sendUiState.isInitialSubscriptionPayment) { + appViewModel.hideSheet() + } else { + appViewModel.navigateToReportIssue( + route.supportMessage( + paymentMethod = route.supportPaymentMethod(sendUiState.payMethod), + routingCacheResetAttempted = routingCacheResetAttempted, + ) ) - ) + } }, ) } @@ -533,6 +564,7 @@ sealed interface SendRoute { data class Pending( val paymentHash: String, val amount: Long, + val observeResolution: Boolean = true, val retryRoute: SendRetryRoute = SendRetryRoute.Confirm, val paymentRequest: String? = null, ) : InternalOnly diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 0b234827f0..4fbe2a38d8 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -24,6 +24,7 @@ import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.Scanner import com.synonym.bitkitcore.SortDirection import com.synonym.bitkitcore.validateBitcoinAddress +import com.synonym.paykit.PaymentRequestLifecycleState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.collections.immutable.ImmutableList @@ -54,6 +55,7 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -67,6 +69,7 @@ import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.ChannelDataMigration import org.lightningdevkit.ldknode.ClosureReason import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.PaymentFailureReason import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.SpendableUtxo @@ -143,6 +146,7 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LnurlPayInvoiceMismatchError import to.bitkit.repositories.MethodId import to.bitkit.repositories.NodeEventUpdate +import to.bitkit.repositories.PaykitOnchainPaymentProofResolution import to.bitkit.repositories.PaykitPaymentProofKind import to.bitkit.repositories.PaykitPaymentProofRepo import to.bitkit.repositories.PaykitPaymentRequest @@ -152,6 +156,8 @@ import to.bitkit.repositories.PaykitPaymentRequestError import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaykitPaymentRequestTarget +import to.bitkit.repositories.PaykitSubscription +import to.bitkit.repositories.PaykitSubscriptionId import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentNotification import to.bitkit.repositories.PendingPaymentRepo @@ -172,6 +178,7 @@ import to.bitkit.services.MigrationService import to.bitkit.services.NodeServiceFgState import to.bitkit.ui.Routes import to.bitkit.ui.components.Sheet +import to.bitkit.ui.components.SubscriptionRoute import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.ui.shared.toast.ToastQueueManager import to.bitkit.ui.sheets.SendRoute @@ -184,6 +191,7 @@ import to.bitkit.utils.AppError import to.bitkit.utils.Bip21Utils import to.bitkit.utils.Logger import to.bitkit.utils.NetworkValidationHelper +import to.bitkit.utils.ServiceError import to.bitkit.utils.jsonLogOf import to.bitkit.utils.timedsheets.TimedSheetManager import to.bitkit.utils.timedsheets.sheets.AppUpdateTimedSheet @@ -299,6 +307,7 @@ class AppViewModel @Inject constructor( val paymentRequestHistory = paykitPaymentRequestRepo.paymentRequestHistory val eligiblePaymentRequestTargets = paykitPaymentRequestRepo.eligibleTargets val isCreatingPaymentRequest = paykitPaymentRequestRepo.isCreatingRequest + val subscriptions = paykitPaymentRequestRepo.subscriptions val pubkyContacts = pubkyRepo.contacts private var sheetTransitionJob: Job? = null private var paymentRequestSheetTransitionJob: Job? = null @@ -317,6 +326,10 @@ class AppViewModel @Inject constructor( private var activeContactPaymentContext: ContactPaymentContext? = null private val pendingContactPaymentContexts = mutableMapOf() private var requestedPaymentRequestId: PaykitPaymentRequestId? = null + private var requestedPaymentRequestIdentity: String? = null + private var requestedPaymentRequestTags: ImmutableList = persistentListOf() + private var uncertainOnchainPaymentRequestId: PaykitPaymentRequestId? = null + private val initialSubscriptionPaymentRequestIds = mutableSetOf() private var isPresentingPaymentRequest = false private var paymentRequestPresentationGeneration = 0L private var activePaymentRequestPresentationGeneration: Long? = null @@ -456,6 +469,7 @@ class AppViewModel @Inject constructor( observePaykitPaymentRequestConnectivity() observeInitialPaykitLinkBursts() observeIncomingPaykitPaymentRequests() + observePaykitOnchainPaymentResolution() observeSendEvents() viewModelScope.launch { checkCriticalAppUpdate() @@ -596,12 +610,11 @@ class AppViewModel @Inject constructor( if (!state.isPaykitEnabled || state.publicKey == null) { isPaymentRequestIdentityActivating = true lastPrivatePaykitContactKeys = emptySet() - invalidatePaymentRequestPresentation(dismissActiveRequest = paymentRequestIdentity != null) - clearPaymentRequestPresentationRetries() + resetPaykitPresentationState( + dismissActiveRequest = paymentRequestIdentity != null, + preserveRequestedPaymentRequest = paymentRequestIdentity == null, + ) paymentRequestIdentity = null - requestedPaymentRequestId = null - paymentRequestSheetTransitionJob?.cancel() - paymentRequestSheetTransitionJob = null try { paykitPaymentRequestRepo.clear() } finally { @@ -613,11 +626,11 @@ class AppViewModel @Inject constructor( val identityChanged = !PubkyPublicKeyFormat.matches(paymentRequestIdentity, state.publicKey) if (identityChanged) { - invalidatePaymentRequestPresentation(dismissActiveRequest = paymentRequestIdentity != null) - clearPaymentRequestPresentationRetries() - requestedPaymentRequestId = null - paymentRequestSheetTransitionJob?.cancel() - paymentRequestSheetTransitionJob = null + paykitPaymentProofRepo.clearOnchainPaymentResolution() + resetPaykitPresentationState( + dismissActiveRequest = paymentRequestIdentity != null, + preserveRequestedPaymentRequest = paymentRequestIdentity == null, + ) } isPaymentRequestIdentityActivating = true @@ -653,6 +666,7 @@ class AppViewModel @Inject constructor( isPaymentRequestIdentityActivating = false } } + presentNextIncomingPaykitPaymentRequest() } private suspend fun refreshPrivatePaykitEndpointsIfEnabled( @@ -689,6 +703,62 @@ class AppViewModel @Inject constructor( } } + private fun observePaykitOnchainPaymentResolution() { + viewModelScope.launch { + paykitPaymentProofRepo.onchainPaymentResolution + .filterNotNull() + .collect(::handlePaykitOnchainPaymentResolution) + } + } + + private fun handlePaykitOnchainPaymentResolution(resolution: PaykitOnchainPaymentProofResolution) { + if (!PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, resolution.identity)) return + paykitPaymentProofRepo.consumeOnchainPaymentResolution(resolution) + val resolvesCurrentPayment = uncertainOnchainPaymentRequestId == resolution.requestId + if (!resolvesCurrentPayment) { + synchronizeResolvedPaykitOnchainPayment(resolution, updateSendDetails = false) + return + } + uncertainOnchainPaymentRequestId = null + if ( + _currentSheet.value !is Sheet.Send || + _sendUiState.value.incomingPaymentRequestId != resolution.requestId + ) { + synchronizeResolvedPaykitOnchainPayment(resolution, updateSendDetails = false) + return + } + onSendSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = resolution.transactionId, + sats = _sendUiState.value.amount.toLong(), + isLoadingDetails = true, + ) + ) + synchronizeResolvedPaykitOnchainPayment(resolution, updateSendDetails = true) + } + + private fun synchronizeResolvedPaykitOnchainPayment( + resolution: PaykitOnchainPaymentProofResolution, + updateSendDetails: Boolean, + ) { + viewModelScope.launch { + lightningRepo.sync() + activityRepo.syncActivities() + activityRepo.setContact( + contactPublicKey = resolution.requestId.counterparty, + forPaymentId = resolution.transactionId, + syncLdkPayments = false, + ).onFailure { + Logger.warn("Failed to associate a resolved Paykit payment with its contact", it, context = TAG) + } + if (updateSendDetails) { + _successSendUiState.update { it.copy(isLoadingDetails = false) } + } + } + } + private suspend fun refreshIncomingPaykitPaymentRequests(): Boolean { if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return false paykitPaymentProofRepo.reconcile() @@ -721,6 +791,27 @@ class AppViewModel @Inject constructor( startInitialPaykitPaymentRequestPolling() } + fun synchronizeSubscriptionNotifications(enabled: Boolean) { + paykitPaymentRequestRepo.synchronizeSubscriptionNotifications(enabled) + } + + fun onPaykitSubscriptionNotificationTapped( + payerIdentity: String?, + requestId: PaykitPaymentRequestId? = null, + ) { + if (payerIdentity != null && requestId != null) { + val currentIdentity = pubkyRepo.publicKey.value + if (currentIdentity != null && !PubkyPublicKeyFormat.matches(currentIdentity, payerIdentity)) return + invalidatePaymentRequestPresentation() + requestedPaymentRequestId = requestId + requestedPaymentRequestIdentity = payerIdentity + requestedPaymentRequestTags = persistentListOf() + } + viewModelScope.launch { + refreshIncomingPaykitPaymentRequests() + } + } + fun stopPaykitPaymentRequestPolling() { paykitPaymentRequestPollingJob?.cancel() paykitPaymentRequestPollingJob = null @@ -765,13 +856,21 @@ class AppViewModel @Inject constructor( } fun onSheetVisible(sheet: Sheet?) { + if (sheet is Sheet.Subscription && sheet.route is SubscriptionRoute.Review) { + subscription(sheet.route.id)?.let { subscription -> + viewModelScope.launch { + paykitPaymentRequestRepo.markSubscriptionProposalPresented(subscription) + } + } + return + } if (sheet !is Sheet.Send || currentSheet.value !is Sheet.Send) return val request = activeIncomingPaymentRequest() ?: return viewModelScope.launch { if (currentSheet.value !is Sheet.Send || activeIncomingPaymentRequest()?.id != request.id) return@launch if (paykitPaymentRequestRepo.markPresented(request)) { paymentRequestPresentationGeneration++ - requestedPaymentRequestId = null + clearRequestedPaymentRequest() clearPaymentRequestPresentationRetry(request.id) } } @@ -779,6 +878,12 @@ class AppViewModel @Inject constructor( private suspend fun presentNextIncomingPaykitPaymentRequest() { if (isPresentingPaymentRequest || isPaymentRequestPresentationBlocked()) return + if (requestedPaymentRequestId == null) { + paykitPaymentRequestRepo.automaticSubscriptionProposals().firstOrNull()?.let { + showSheet(Sheet.Subscription(SubscriptionRoute.Review(it.id))) + return + } + } val requests = paymentRequestsForPresentation() ?: return val generation = paymentRequestPresentationGeneration isPresentingPaymentRequest = true @@ -807,19 +912,32 @@ class AppViewModel @Inject constructor( private fun paymentRequestsForPresentation(): List? { val requestedId = requestedPaymentRequestId - if (requestedId != null && paymentRequestPresentationRetryJobs[requestedId]?.isActive == true) return null - if (requestedId != null) { - val request = paykitPaymentRequestRepo.pendingRequest(requestedId) - if (request != null) return listOf(request) - invalidatePaymentRequestPresentation() - requestedPaymentRequestId = null - return null + return if (requestedId == null) { + paykitPaymentRequestRepo.automaticPendingRequests().filter { request -> + !paykitPaymentRequestRepo.isProcessing(request) && + paymentRequestPresentationRetryJobs[request.id]?.isActive != true + }.takeIf { it.isNotEmpty() } + } else { + when { + !requestedPaymentRequestTargetsCurrentIdentity() -> { + invalidatePaymentRequestPresentation() + clearRequestedPaymentRequest() + null + } + paymentRequestPresentationRetryJobs[requestedId]?.isActive == true -> null + else -> paykitPaymentRequestRepo.pendingRequest(requestedId)?.let(::listOf) ?: run { + invalidatePaymentRequestPresentation() + clearRequestedPaymentRequest() + null + } + } } + } - return paykitPaymentRequestRepo.automaticPendingRequests().filter { request -> - !paykitPaymentRequestRepo.isProcessing(request) && - paymentRequestPresentationRetryJobs[request.id]?.isActive != true - }.takeIf { it.isNotEmpty() } + private fun requestedPaymentRequestTargetsCurrentIdentity(): Boolean { + val requestedIdentity = requestedPaymentRequestIdentity ?: return true + val currentIdentity = pubkyRepo.publicKey.value ?: return false + return PubkyPublicKeyFormat.matches(currentIdentity, requestedIdentity) } private suspend fun presentIncomingPaymentRequestOrStop( @@ -833,7 +951,7 @@ class AppViewModel @Inject constructor( if (!paykitPaymentRequestRepo.isPending(request)) { if (requestedPaymentRequestId == request.id) { invalidatePaymentRequestPresentation() - requestedPaymentRequestId = null + clearRequestedPaymentRequest() } return false } @@ -847,6 +965,9 @@ class AppViewModel @Inject constructor( publicKey = request.counterparty, privatePaymentContext = result.privatePaymentContext, incomingPaymentRequest = request, + isInitialSubscriptionPayment = initialSubscriptionPaymentRequestIds.remove(request.id), + selectedTags = requestedPaymentRequestTags.takeIf { requestedPaymentRequestId == request.id } + ?: persistentListOf(), ) return true } @@ -866,7 +987,7 @@ class AppViewModel @Inject constructor( context = TAG, ) paymentRequestPresentationGeneration++ - requestedPaymentRequestId = null + clearRequestedPaymentRequest() showSheet(Sheet.PaymentRequests) viewModelScope.launch { paykitPaymentRequestRepo.markPresented(request) @@ -895,12 +1016,13 @@ class AppViewModel @Inject constructor( private fun retainPaymentRequestPresentationState(requests: List) { val requestIds = requests.mapTo(mutableSetOf()) { it.id } paymentRequestPresentationRetryAttempts.keys.retainAll(requestIds) + initialSubscriptionPaymentRequestIds.retainAll(requestIds) paymentRequestPresentationRetryJobs.keys.filter { it !in requestIds }.forEach { paymentRequestPresentationRetryJobs.remove(it)?.cancel() } if (requestedPaymentRequestId?.let { it !in requestIds } == true) { invalidatePaymentRequestPresentation() - requestedPaymentRequestId = null + clearRequestedPaymentRequest() } } @@ -915,6 +1037,23 @@ class AppViewModel @Inject constructor( paymentRequestPresentationRetryAttempts.clear() } + private fun resetPaykitPresentationState( + dismissActiveRequest: Boolean, + preserveRequestedPaymentRequest: Boolean, + ) { + invalidatePaymentRequestPresentation(dismissActiveRequest) + clearPaymentRequestPresentationRetries() + if (!preserveRequestedPaymentRequest) clearRequestedPaymentRequest() + paymentRequestSheetTransitionJob?.cancel() + paymentRequestSheetTransitionJob = null + } + + private fun clearRequestedPaymentRequest() { + requestedPaymentRequestId = null + requestedPaymentRequestIdentity = null + requestedPaymentRequestTags = persistentListOf() + } + private fun invalidatePaymentRequestPresentation(dismissActiveRequest: Boolean = false) { paymentRequestPresentationGeneration++ scheduledScan @@ -1306,7 +1445,8 @@ class AppViewModel @Inject constructor( private suspend fun handlePaymentFailed(event: Event.PaymentFailed) { (event.paymentHash ?: event.paymentId)?.let { paymentHash -> - viewModelScope.launch { paykitPaymentProofRepo.failLightningPayment(paymentHash) } + paykitPaymentProofRepo.failLightningPayment(paymentHash) + refreshIncomingPaykitPaymentRequests() } event.paymentHash?.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) @@ -1386,6 +1526,7 @@ class AppViewModel @Inject constructor( private suspend fun handlePaymentSuccessful(event: Event.PaymentSuccessful) { viewModelScope.launch { paykitPaymentProofRepo.completeLightningPayment(event.paymentHash, event.paymentPreimage) + refreshIncomingPaykitPaymentRequests() } event.paymentHash.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) @@ -1549,6 +1690,8 @@ class AppViewModel @Inject constructor( } } SendEvent.SwipeToPay -> onSwipeToPay() + SendEvent.StartInitialSubscriptionPayment -> onStartInitialSubscriptionPayment() + SendEvent.CancelInitialSubscriptionPayment -> onCancelInitialSubscriptionPayment() is SendEvent.ConfirmAmountWarning -> onConfirmAmountWarning(it.warning) SendEvent.DismissAmountWarning -> onDismissAmountWarning() SendEvent.EstimateMaxRoutingFee -> viewModelScope.launch { @@ -1792,7 +1935,7 @@ class AppViewModel @Inject constructor( routePubkyKeys: Boolean = false, contactPaymentContext: ContactPaymentContext? = null, preserveUntilComplete: Boolean = false, - ) { + ): Job? { if (!_isAuthenticated.value) { enqueueDeferredScan( source = source, @@ -1801,7 +1944,7 @@ class AppViewModel @Inject constructor( routePubkyKeys = routePubkyKeys, contactPaymentContext = contactPaymentContext, ) - return + return null } val normalized = data.removeLightningSchemes() @@ -1813,12 +1956,12 @@ class AppViewModel @Inject constructor( (scheduled.contactPaymentContext == contactPaymentContext || contactPaymentContext == null) if (isSameActiveScan) { Logger.info("Skipping duplicate scan from '${source.label}': '$scanId'", context = TAG) - return + return null } if (scheduled?.job?.isActive == true && scheduled.mustComplete) { enqueueDeferredScan(source, data, startDelay, routePubkyKeys, contactPaymentContext) - return + return null } val previousJob = scheduled?.job @@ -1849,6 +1992,7 @@ class AppViewModel @Inject constructor( Logger.info("Cancelling prior scan for new '${source.label}': '$scanId'", context = TAG) it.cancel() } + return nextJob } private fun scanLogId(data: String): String { @@ -2176,13 +2320,21 @@ class AppViewModel @Inject constructor( publicKey: String, privatePaymentContext: PrivatePaykitPaymentContext? = null, incomingPaymentRequest: PaykitPaymentRequest? = null, - ) { + isInitialSubscriptionPayment: Boolean = false, + selectedTags: ImmutableList = persistentListOf(), + ): Job? { val context = ContactPaymentContext( publicKey = publicKey, privatePaymentContext = privatePaymentContext, incomingPaymentRequest = incomingPaymentRequest, + isInitialSubscriptionPayment = isInitialSubscriptionPayment, + selectedTags = selectedTags, + ) + return launchScan( + source = ScanSource.SCAN_RESULT, + data = paymentRequest, + contactPaymentContext = context, ) - onScanResult(paymentRequest, contactPaymentContext = context) } fun preserveContactPaymentContext(paymentHash: String) { @@ -2201,9 +2353,21 @@ class AppViewModel @Inject constructor( routePubkyKeys: Boolean, ) = withContext(bgDispatcher) { val contactPaymentProfile = activeContactPaymentProfile() - val isPaymentRequest = activeIncomingPaymentRequest() != null + val incomingPaymentRequest = activeIncomingPaymentRequest() + val isPaymentRequest = incomingPaymentRequest != null // always reset state on new scan - resetSendState(contactPaymentProfile = contactPaymentProfile, isPaymentRequest = isPaymentRequest) + resetSendState( + contactPaymentProfile = contactPaymentProfile, + isPaymentRequest = isPaymentRequest, + isSubscriptionPayment = incomingPaymentRequest?.billingPeriod != null, + isInitialSubscriptionPayment = synchronized(contactPaymentContextLock) { + activeContactPaymentContext?.isInitialSubscriptionPayment == true + }, + incomingPaymentRequestId = incomingPaymentRequest?.id, + selectedTags = synchronized(contactPaymentContextLock) { + activeContactPaymentContext?.selectedTags ?: persistentListOf() + }, + ) resetQuickPay() val fromMainScanner = isMainScanner @@ -2866,6 +3030,18 @@ class AppViewModel @Inject constructor( } } + private fun onStartInitialSubscriptionPayment() { + if (!_sendUiState.value.initialSubscriptionPaymentAutoStartPending) return + _sendUiState.update { it.copy(initialSubscriptionPaymentAutoStartPending = false) } + onSwipeToPay() + } + + private fun onCancelInitialSubscriptionPayment() { + if (!_sendUiState.value.isInitialSubscriptionPayment) return + val contactPaymentContext = synchronized(contactPaymentContextLock) { activeContactPaymentContext } + handlePaymentPreparationFailure(PaykitPaymentRequestError.RequestUnavailable, contactPaymentContext) + } + @Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount") private suspend fun handleSanityChecks(amountSats: ULong) { if (_sendUiState.value.showSanityWarningDialog != null) return @@ -2942,23 +3118,23 @@ class AppViewModel @Inject constructor( if (!validateIncomingPaymentRequest(contactPaymentContext)) return val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest - var preparedPaymentProofRequest = preparePaymentProof(incomingPaymentRequest).fold( + val preparedPaymentProofRequest = preparePaymentProof(incomingPaymentRequest).fold( onSuccess = { it }, onFailure = { - handlePaymentPreparationFailure(it) + handlePaymentPreparationFailure(it, contactPaymentContext) return }, ) consumePrivatePaymentListIfNeeded(contactPaymentContext).onFailure { cancelPaymentProofPreparation(preparedPaymentProofRequest) - handlePaymentPreparationFailure(it) + handlePaymentPreparationFailure(it, contactPaymentContext) return } acceptIncomingPaymentRequestIfNeeded(contactPaymentContext).onFailure { cancelPaymentProofPreparation(preparedPaymentProofRequest) - handlePaymentPreparationFailure(it) + handlePaymentPreparationFailure(it, contactPaymentContext) return } @@ -2980,112 +3156,177 @@ class AppViewModel @Inject constructor( }.onFailure { cancelPaymentProofPreparation(preparedPaymentProofRequest) val message = getLnurlInvoiceFetchErrorMessage(it) - toast(Exception(message)) - hideSheet() + handlePaymentPreparationFailure(Exception(message), contactPaymentContext) return } } when (_sendUiState.value.payMethod) { - SendMethod.ONCHAIN -> { - val address = _sendUiState.value.address - val tags = _sendUiState.value.selectedTags - sendOnchain(address, amount, tags = tags) - .onSuccess { txId -> - preparedPaymentProofRequest = null - completeOnchainPaymentProof(incomingPaymentRequest, txId) - Logger.info("Onchain send result txid: $txId", context = TAG) - onSendSuccess( - NewTransactionSheetDetails( - type = NewTransactionSheetType.ONCHAIN, - direction = NewTransactionSheetDirection.SENT, - paymentHashOrTxId = txId, - sats = amount.toLong(), - isLoadingDetails = true, - ) - ) - lightningRepo.sync() - activityRepo.syncActivities() - _successSendUiState.update { it.copy(isLoadingDetails = false) } - }.onFailure { e -> - cancelPaymentProofPreparation(preparedPaymentProofRequest) - Logger.error("Error sending onchain payment", e, context = TAG) - toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.wallet__error_sending_title), - description = e.message ?: context.getString(R.string.common__error_body) - ) - hideSheet() - } - } + SendMethod.ONCHAIN -> proceedWithOnchainPayment( + incomingPaymentRequest, + preparedPaymentProofRequest, + contactPaymentContext, + amount, + ) - SendMethod.LIGHTNING -> { - val decodedInvoice = requireNotNull(_sendUiState.value.decodedInvoice) - val bolt11 = decodedInvoice.bolt11 + SendMethod.LIGHTNING -> proceedWithLightningPayment( + incomingPaymentRequest, + preparedPaymentProofRequest, + contactPaymentContext, + amount, + ) + } + } - val paymentAmount = if (decodedInvoice.amountSatoshis > 0uL) null else amount - val displayAmountSats = decodedInvoice.amountSatoshis.takeIf { it > 0uL } ?: amount ?: 0uL + private suspend fun proceedWithOnchainPayment( + incomingPaymentRequest: PaykitPaymentRequest?, + preparedPaymentProofRequest: PaykitPaymentRequest?, + contactPaymentContext: ContactPaymentContext?, + amount: ULong, + ) { + val address = _sendUiState.value.address + val tags = _sendUiState.value.selectedTags + var proofRequest = preparedPaymentProofRequest + var onchainPaymentStarted = false + sendOnchain( + address = address, + amount = amount, + tags = tags, + beforeSendAttempt = { + if (incomingPaymentRequest != null) { + markOnchainPaymentStarted(incomingPaymentRequest, address).getOrThrow() + onchainPaymentStarted = true + } + }, + onBroadcast = { txId -> + proofRequest = null + completeOnchainPaymentProof(incomingPaymentRequest, txId) + }, + ).onSuccess { txId -> + Logger.info("Onchain send result txid: $txId", context = TAG) + onSendSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = txId, + sats = amount.toLong(), + isLoadingDetails = true, + ) + ) + lightningRepo.sync() + activityRepo.syncActivities() + _successSendUiState.update { it.copy(isLoadingDetails = false) } + }.onFailure { error -> + handleOnchainPaymentFailure( + error = error, + paymentStarted = onchainPaymentStarted, + incomingPaymentRequest = incomingPaymentRequest, + preparedPaymentProofRequest = proofRequest, + contactPaymentContext = contactPaymentContext, + ) + } + } - val tags = _sendUiState.value.selectedTags - var createdMetadataPaymentId: String? = null + private suspend fun handleOnchainPaymentFailure( + error: Throwable, + paymentStarted: Boolean, + incomingPaymentRequest: PaykitPaymentRequest?, + preparedPaymentProofRequest: PaykitPaymentRequest?, + contactPaymentContext: ContactPaymentContext?, + ) { + val amount = _sendUiState.value.amount + if (paymentStarted && !error.isDefiniteOnchainPreBroadcastFailure()) { + Logger.warn("On-chain payment outcome is uncertain after send started", error, context = TAG) + uncertainOnchainPaymentRequestId = incomingPaymentRequest?.id + paykitPaymentProofRepo.onchainPaymentResolution.value?.let(::handlePaykitOnchainPaymentResolution) + if (uncertainOnchainPaymentRequestId == null) return + setSendEffect( + SendEffect.NavigateToPending( + paymentHash = incomingPaymentRequest?.paymentRequestId.orEmpty(), + amount = amount.toLong(), + observeResolution = false, + ) + ) + return + } + if (paymentStarted) { + incomingPaymentRequest?.let { paykitPaymentProofRepo.failOnchainPayment(it) } + } + cancelPaymentProofPreparation(preparedPaymentProofRequest) + Logger.error("Error sending onchain payment", error, context = TAG) + if (contactPaymentContext?.isInitialSubscriptionPayment == true) { + setSendEffect(SendEffect.NavigateToError(error.toSendFailureDetails(context, _sendUiState.value.address))) + } else { + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.wallet__error_sending_title), + description = error.message ?: context.getString(R.string.common__error_body), + ) + hideSheet() + } + } - // Extract payment hash from invoice for pre-activity metadata - val paymentHash = decodedInvoice.paymentHash.toHex() - associateLightningPaymentProof(incomingPaymentRequest, paymentHash).onFailure { - cancelPaymentProofPreparation(preparedPaymentProofRequest) - handlePaymentPreparationFailure(it) - return - } + private suspend fun proceedWithLightningPayment( + incomingPaymentRequest: PaykitPaymentRequest?, + preparedPaymentProofRequest: PaykitPaymentRequest?, + contactPaymentContext: ContactPaymentContext?, + amount: ULong, + ) { + val decodedInvoice = requireNotNull(_sendUiState.value.decodedInvoice) + val paymentAmount = if (decodedInvoice.amountSatoshis > 0uL) null else amount + val displayAmountSats = decodedInvoice.amountSatoshis.takeIf { it > 0uL } ?: amount + var proofRequest = preparedPaymentProofRequest + var createdMetadataPaymentId: String? = null + val paymentHash = decodedInvoice.paymentHash.toHex() + associateLightningPaymentProof(incomingPaymentRequest, paymentHash).onFailure { + cancelPaymentProofPreparation(proofRequest) + handlePaymentPreparationFailure(it, contactPaymentContext) + return + } - // Create pre-activity metadata before sending - if (tags.isNotEmpty()) { - preActivityMetadataRepo.savePreActivityMetadata( - id = paymentHash, - paymentHash = paymentHash, - address = _sendUiState.value.address, - isReceive = false, - tags = tags, - ).onSuccess { - createdMetadataPaymentId = paymentHash - } - } + val tags = _sendUiState.value.selectedTags + if (tags.isNotEmpty()) { + preActivityMetadataRepo.savePreActivityMetadata( + id = paymentHash, + paymentHash = paymentHash, + address = _sendUiState.value.address, + isReceive = false, + tags = tags, + ).onSuccess { + createdMetadataPaymentId = paymentHash + } + } - sendLightning(bolt11, paymentAmount).onSuccess { actualPaymentHash -> - preparedPaymentProofRequest = null - Logger.info("Lightning send result payment hash: $actualPaymentHash", context = TAG) - onSendSuccess( - NewTransactionSheetDetails( - type = NewTransactionSheetType.LIGHTNING, - direction = NewTransactionSheetDirection.SENT, - paymentHashOrTxId = actualPaymentHash, - sats = displayAmountSats.toLong(), // TODO Add fee when available - ), - ) - }.onFailure { - if (it is PaymentPendingException) { - preparedPaymentProofRequest = null - Logger.info("Lightning payment pending", context = TAG) - pendingPaymentRepo.track(it.paymentHash) - preserveContactPaymentContext(it.paymentHash) - setSendEffect(SendEffect.NavigateToPending(it.paymentHash, displayAmountSats.toLong())) - return@onFailure - } - paykitPaymentProofRepo.failLightningPayment(paymentHash) - cancelPaymentProofPreparation(preparedPaymentProofRequest) - // Delete pre-activity metadata on failure - if (createdMetadataPaymentId != null) { - preActivityMetadataRepo.deletePreActivityMetadata(createdMetadataPaymentId) - } - Logger.error("Error sending lightning payment", it, context = TAG) - val failure = when (it) { - is LightningPaymentFailedError -> it.reason.toSendFailureDetails(context, it.paymentRequest) - else -> it.toSendFailureDetails(context, _sendUiState.value.currentLightningPaymentRequest()) - } - setSendEffect( - SendEffect.NavigateToError(failure) - ) - } + sendLightning(decodedInvoice.bolt11, paymentAmount).onSuccess { actualPaymentHash -> + proofRequest = null + Logger.info("Lightning send result payment hash: $actualPaymentHash", context = TAG) + onSendSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = actualPaymentHash, + sats = displayAmountSats.toLong(), + ), + ) + }.onFailure { error -> + if (error is PaymentPendingException) { + proofRequest = null + Logger.info("Lightning payment pending", context = TAG) + pendingPaymentRepo.track(error.paymentHash) + preserveContactPaymentContext(error.paymentHash) + refreshIncomingPaykitPaymentRequests() + setSendEffect(SendEffect.NavigateToPending(error.paymentHash, displayAmountSats.toLong())) + return@onFailure } + paykitPaymentProofRepo.failLightningPayment(paymentHash) + cancelPaymentProofPreparation(proofRequest) + createdMetadataPaymentId?.let { preActivityMetadataRepo.deletePreActivityMetadata(it) } + Logger.error("Error sending lightning payment", error, context = TAG) + val failure = when (error) { + is LightningPaymentFailedError -> error.reason.toSendFailureDetails(context, error.paymentRequest) + else -> error.toSendFailureDetails(context, _sendUiState.value.currentLightningPaymentRequest()) + } + setSendEffect(SendEffect.NavigateToError(failure)) } } @@ -3112,9 +3353,16 @@ class AppViewModel @Inject constructor( txid = txId, paymentEndpointIdentifier = paymentProofPreparation().endpointIdentifier, ) + if (it.billingPeriod != null) refreshIncomingPaykitPaymentRequests() } } + private suspend fun markOnchainPaymentStarted( + request: PaykitPaymentRequest?, + address: String, + ): Result = request?.let { paykitPaymentProofRepo.markOnchainPaymentStarted(it, address) } + ?: Result.success(Unit) + private suspend fun cancelPaymentProofPreparation(request: PaykitPaymentRequest?) { request?.let { paykitPaymentProofRepo.cancelPreparation(it) } } @@ -3300,7 +3548,10 @@ class AppViewModel @Inject constructor( address: String, amount: ULong, tags: List = emptyList(), + beforeSendAttempt: suspend () -> Unit = {}, + onBroadcast: suspend (Txid) -> Unit = {}, ): Result { + var broadcastTxId: Txid? = null return lightningRepo.sendOnChain( address = address, sats = amount, @@ -3309,7 +3560,12 @@ class AppViewModel @Inject constructor( isMaxAmount = _sendUiState.value.payMethod == SendMethod.ONCHAIN && amount == walletRepo.balanceState.value.maxSendOnchainSats, tags = tags, - ) + beforeSendAttempt = beforeSendAttempt, + onBroadcast = { + broadcastTxId = it + onBroadcast(it) + }, + ).recoverCatching { broadcastTxId ?: throw it } } private suspend fun sendLightning( @@ -3491,6 +3747,10 @@ class AppViewModel @Inject constructor( suspend fun resetSendState( contactPaymentProfile: PubkyProfile? = null, isPaymentRequest: Boolean = false, + isSubscriptionPayment: Boolean = false, + isInitialSubscriptionPayment: Boolean = false, + incomingPaymentRequestId: PaykitPaymentRequestId? = null, + selectedTags: ImmutableList = persistentListOf(), ) { addressValidationJob?.cancel() val speed = settingsStore.data.first().defaultTransactionSpeed @@ -3506,6 +3766,11 @@ class AppViewModel @Inject constructor( feeRates = rates, contactPaymentProfile = contactPaymentProfile, isPaymentRequest = isPaymentRequest, + isSubscriptionPayment = isSubscriptionPayment, + isInitialSubscriptionPayment = isInitialSubscriptionPayment, + initialSubscriptionPaymentAutoStartPending = isInitialSubscriptionPayment, + incomingPaymentRequestId = incomingPaymentRequestId, + selectedTags = selectedTags, ) } } @@ -3917,7 +4182,94 @@ class AppViewModel @Inject constructor( showSheet(Sheet.PaymentRequests) } + fun subscription(id: PaykitSubscriptionId): PaykitSubscription? = + paykitPaymentRequestRepo.subscriptions.value.firstOrNull { it.id == id } + + fun subscriptionAcceptedAt(id: PaykitSubscriptionId) = + subscription(id)?.let(paykitPaymentRequestRepo::acceptedAt) + + suspend fun acceptSubscriptionAndStartPayment( + displayedSubscription: PaykitSubscription, + ): Result = runSuspendCatching { + val subscription = subscription(displayedSubscription.id) + ?.takeIf { it == displayedSubscription } + ?: throw PaykitPaymentRequestError.RequestUnavailable + val acceptedDueRequest = paykitPaymentRequestRepo.accept(subscription).getOrThrow() + if (acceptedDueRequest == null) { + val accepted = subscription(displayedSubscription.id) + if (accepted?.lifecycleState != PaymentRequestLifecycleState.ACTIVE_RECURRING) { + throw PaykitPaymentRequestError.RequestUnavailable + } + return@runSuspendCatching false + } + + val resolution = privatePaykitRepo.beginPaymentRequestWaitingForUpdatedList(acceptedDueRequest) + .getOrElse { error -> + showInitialSubscriptionPaymentFailure(acceptedDueRequest, error) + return@runSuspendCatching true + } + if (resolution !is PublicPaykitPaymentResult.Opened) { + showInitialSubscriptionPaymentFailure( + acceptedDueRequest, + PaykitPaymentRequestError.RequestUnavailable, + ) + return@runSuspendCatching true + } + val scanJob = openContactPayment( + paymentRequest = resolution.paymentRequest, + publicKey = acceptedDueRequest.counterparty, + privatePaymentContext = resolution.privatePaymentContext, + incomingPaymentRequest = acceptedDueRequest, + isInitialSubscriptionPayment = true, + ) + scanJob?.join() + if (_currentSheet.value !is Sheet.Send) { + paykitPaymentRequestRepo.markPresented(acceptedDueRequest) + val error = PaykitPaymentRequestError.RequestUnavailable + val failure = error.toSendFailureDetails(context, _sendUiState.value.currentLightningPaymentRequest()) + showSheet(Sheet.Send(SendRoute.errorFromFailure(failure))) + } + true + }.onFailure(::toast) + + private suspend fun showInitialSubscriptionPaymentFailure( + request: PaykitPaymentRequest, + error: Throwable, + ) { + val paymentContext = ContactPaymentContext( + publicKey = request.counterparty, + incomingPaymentRequest = request, + isInitialSubscriptionPayment = true, + ) + setActiveContactPaymentContext(paymentContext) + resetSendState( + contactPaymentProfile = activeContactPaymentProfile(), + isPaymentRequest = true, + isSubscriptionPayment = true, + isInitialSubscriptionPayment = true, + incomingPaymentRequestId = request.id, + ) + paykitPaymentRequestRepo.markPresented(request) + showSheet( + Sheet.Send( + SendRoute.errorFromFailure( + error.toSendFailureDetails(context, paymentRequest = null) + ) + ) + ) + } + + suspend fun cancelSubscription(id: PaykitSubscriptionId): Result { + val subscription = subscription(id) ?: return Result.failure(PaykitPaymentRequestError.RequestUnavailable) + return paykitPaymentRequestRepo.cancel(subscription) + .onFailure(::toast) + } + fun openIncomingPaymentRequest(id: PaykitPaymentRequestId) { + openIncomingPaymentRequestWithTags(id, emptyList()) + } + + fun openIncomingPaymentRequestWithTags(id: PaykitPaymentRequestId, tags: List) { val request = paykitPaymentRequestRepo.pendingRequest(id) ?: return if (paykitPaymentRequestRepo.isProcessing(request) || requestedPaymentRequestId != null) { toast(PaykitPaymentRequestError.OperationInProgress) @@ -3925,8 +4277,13 @@ class AppViewModel @Inject constructor( } invalidatePaymentRequestPresentation() requestedPaymentRequestId = id + requestedPaymentRequestTags = tags.filter(String::isNotBlank).distinct().toImmutableList() - if (_currentSheet.value is Sheet.PaymentRequests) { + if ( + _currentSheet.value is Sheet.PaymentRequests || + _currentSheet.value is Sheet.Subscription || + _currentSheet.value is Sheet.Send + ) { hideSheet(shouldFlushDeferredScan = false) paymentRequestSheetTransitionJob?.cancel() val job = viewModelScope.launch { @@ -3940,11 +4297,22 @@ class AppViewModel @Inject constructor( } } - suspend fun rejectIncomingPaymentRequest(request: PaykitPaymentRequest): Result { + fun retryIncomingPaymentRequest(id: PaykitPaymentRequestId) { + if (_sendUiState.value.isInitialSubscriptionPayment) { + initialSubscriptionPaymentRequestIds += id + } + clearActiveContactPaymentContext() + viewModelScope.launch { + refreshIncomingPaykitPaymentRequests() + openIncomingPaymentRequestWithTags(id, _sendUiState.value.selectedTags) + } + } + + suspend fun dismissIncomingPaymentRequest(request: PaykitPaymentRequest): Result { if (requestedPaymentRequestId == request.id) { return Result.failure(PaykitPaymentRequestError.OperationInProgress).onFailure(::toast) } - return paykitPaymentRequestRepo.reject(request).onFailure(::toast) + return paykitPaymentRequestRepo.dismiss(request).onFailure(::toast) } private suspend fun createPaymentRequest( @@ -3985,9 +4353,17 @@ class AppViewModel @Inject constructor( } } - private fun handlePaymentPreparationFailure(error: Throwable) { - toast(error) - hideSheet() + private fun handlePaymentPreparationFailure(error: Throwable, contactPaymentContext: ContactPaymentContext?) { + if (contactPaymentContext?.isInitialSubscriptionPayment == true) { + setSendEffect( + SendEffect.NavigateToError( + error.toSendFailureDetails(context, _sendUiState.value.currentLightningPaymentRequest()) + ) + ) + } else { + toast(error) + hideSheet() + } } fun handleDeeplinkIntent(intent: Intent) { @@ -4301,6 +4677,10 @@ data class SendUiState( val lastLightningFee: Long = 0L, val contactPaymentProfile: PubkyProfile? = null, val isPaymentRequest: Boolean = false, + val isSubscriptionPayment: Boolean = false, + val isInitialSubscriptionPayment: Boolean = false, + val initialSubscriptionPaymentAutoStartPending: Boolean = false, + val incomingPaymentRequestId: PaykitPaymentRequestId? = null, ) enum class SanityWarning(@StringRes val message: Int, val testTag: String) { @@ -4322,6 +4702,8 @@ data class ContactPaymentContext( val publicKey: String, val privatePaymentContext: PrivatePaykitPaymentContext? = null, val incomingPaymentRequest: PaykitPaymentRequest? = null, + val isInitialSubscriptionPayment: Boolean = false, + val selectedTags: ImmutableList = persistentListOf(), ) private data class PaymentProofPreparation( @@ -4352,7 +4734,11 @@ sealed class SendEffect { data object NavigateToComingSoon : SendEffect() data object PaymentSuccess : SendEffect() data class NavigateToError(val failure: SendFailureDetails) : SendEffect() - data class NavigateToPending(val paymentHash: String, val amount: Long) : SendEffect() + data class NavigateToPending( + val paymentHash: String, + val amount: Long, + val observeResolution: Boolean = true, + ) : SendEffect() } sealed class MainScreenEffect { @@ -4383,6 +4769,8 @@ sealed interface SendEvent { data class CommentChange(val value: String) : SendEvent data object SwipeToPay : SendEvent + data object StartInitialSubscriptionPayment : SendEvent + data object CancelInitialSubscriptionPayment : SendEvent data object SpeedAndFee : SendEvent data object PaymentMethodSwitch : SendEvent data class ConfirmAmountWarning(val warning: SanityWarning) : SendEvent @@ -4400,6 +4788,23 @@ private class LightningPaymentFailedError( val paymentRequest: String?, ) : AppError(reason?.name) +private fun Throwable.isDefiniteOnchainPreBroadcastFailure(): Boolean = + generateSequence(this as Throwable?) { it.cause } + .any { + it is ServiceError.NodeNotSetup || + it is ServiceError.NodeNotStarted || + it is NodeException.NotRunning || + it is NodeException.OnchainTxCreationFailed || + it is NodeException.OnchainTxSigningFailed || + it is NodeException.InvalidAddress || + it is NodeException.InvalidAmount || + it is NodeException.InvalidNetwork || + it is NodeException.InvalidFeeRate || + it is NodeException.InsufficientFunds || + it is NodeException.CoinSelectionFailed || + it is NodeException.NoSpendableOutputs + } + sealed interface LnurlParams { data class LnurlPay(val data: LnurlPayData) : LnurlParams data class LnurlWithdraw(val data: LnurlWithdrawData) : LnurlParams diff --git a/app/src/main/res/drawable-nodpi/subscription_clock.png b/app/src/main/res/drawable-nodpi/subscription_clock.png new file mode 100644 index 0000000000000000000000000000000000000000..2633f499bc3e0d8a9ca332602f875d83032d9bdf GIT binary patch literal 85117 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 43537a468b..8defb150f8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1014,6 +1014,57 @@ Set up your public profile and links, so your Bitkit contacts can reach you or pay you anytime, anywhere. Profile Create Profile + Active + Cancel + Cancel Subscription + Daily + Daily Subscription + Subscription Details + Due This Month + At the moment, you don’t have any active subscriptions from any providers. + Subscriptions]]> + Every %1$d days + Every %1$d months + Every %1$d weeks + Every %1$d years + Expired + Expires + Expires %1$s + First Payment Failed + You’re subscribed, but your first payment wasn’t sent. + Frequency + More Info + Monthly + Monthly Subscription + Ongoing + Overview + Open Bitkit to review a subscription payment. + Subscription Payment Due + Payments + Per Day + Per Month + Per Week + Per Year + Proposals + Renews + Renews %1$s + Review & Subscribe + Retry Payment + Status + Subscribed + Subscription + Swipe To Cancel + Swipe To Subscribe + Swipe To Subscribe & Pay + Subscriptions + This subscription is no longer available. + This subscription uses a payment frequency that Bitkit does not support yet. + Unsupported Frequency + This subscription uses payment details that Bitkit does not support yet. + Weekly + Weekly Subscription + Yearly + Yearly Subscription %1$s sats Please wait while Bitkit looks for funds in unsupported addresses (Legacy, Nested SegWit, and Taproot). LOOKING FOR FUNDS... @@ -1125,7 +1176,6 @@ Incoming Transfer: Activity Contacts - Requests Profile Settings Shop @@ -1171,9 +1221,10 @@ Payment Request Amount Choose Recipient + Contact %1$s - %2$s Dismiss - Edit expiration + Date Enter pubky Expires in 1 day @@ -1184,10 +1235,14 @@ Note What is this payment for? Pay + Request Or Pay + Pay %1$s or request a payment. + Or Pay ₿]]> Paste Your payment request is queued and will send automatically RECIPIENT Request Payment + Request Send Payment Request Send Request You have sent a payment request @@ -1202,6 +1257,7 @@ Rejected Unavailable %1$s at %2$s + Time Waiting for payment Waiting for %1$s to pay Waiting for updated private payment details. Bitkit will retry automatically. @@ -1221,7 +1277,6 @@ This Week This Year Today - Yesterday Bitkit tried several Lightning routes, but the payment could not be completed. Bitkit couldn\'t find a Lightning route for this payment. Payment timed out. Please try again. diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index bace953693..2a51e13f14 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -1,5 +1,6 @@ package to.bitkit.repositories +import com.synonym.paykit.BillingPeriod import com.synonym.paykit.IdentityStatus import com.synonym.paykit.PaymentProofRecord import com.synonym.paykit.PaymentReference @@ -12,14 +13,12 @@ import com.synonym.paykit.PrivateJsonObject import kotlinx.coroutines.test.StandardTestDispatcher import org.junit.Before import org.junit.Test -import org.lightningdevkit.ldknode.PaymentDetails -import org.lightningdevkit.ldknode.PaymentDirection -import org.lightningdevkit.ldknode.PaymentKind -import org.lightningdevkit.ldknode.PaymentStatus import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times @@ -31,6 +30,7 @@ import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Instant class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { companion object { @@ -38,11 +38,13 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { private const val COUNTERPARTY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private const val PAYMENT_REQUEST_ID = "550e8400-e29b-41d4-a716-446655440000" private const val PAYMENT_HASH = "66687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925" + private const val ONCHAIN_ADDRESS = "bcrt1qpaymentproof" private val PREIMAGE = "00".repeat(32) } private val paykitSdkService = mock() private val lightningRepo = mock() + private val onchainPaymentLookup = mock() private val store = mock() private var storedProofs = emptyList() private var shouldFailNextLoad = false @@ -55,6 +57,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { shouldFailNextSave = false whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) + whenever(onchainPaymentLookup.existingTransactionIds(any(), any())).thenReturn(emptySet()) whenever(store.load()).thenAnswer { if (shouldFailNextLoad) { shouldFailNextLoad = false @@ -65,7 +68,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(store.save(any())).doSuspendableAnswer { if (shouldFailNextSave) { shouldFailNextSave = false - error("temporary save failure") + error("transient save failure") } storedProofs = it.getArgument(0) } @@ -76,7 +79,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() val request = paymentRequest(MethodId.Bolt11.rawValue) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) .thenThrow(IllegalStateException("temporary failure")) .thenReturn(record) val firstRepo = paymentProofRepo() @@ -97,6 +100,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentRequestId = any(), paymentEndpointIdentifier = endpointCaptor.capture(), proofJson = proofCaptor.capture(), + billingPeriod = isNull(), ) assertEquals(MethodId.Bolt11.rawValue, endpointCaptor.lastValue) assertEquals( @@ -107,46 +111,6 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(paykitSdkService).processPendingPrivateMessages() } - @Test - fun `associated lightning proof completes after repository restart`() = test { - val record = paymentRequestRecord() - val request = paymentRequest(MethodId.Bolt11.rawValue) - val paymentKind = mock { - on { preimage } doReturn PREIMAGE - } - val payment = mock { - on { id } doReturn PAYMENT_HASH - on { kind } doReturn paymentKind - on { direction } doReturn PaymentDirection.OUTBOUND - on { status } doReturn PaymentStatus.SUCCEEDED - } - whenever(lightningRepo.getPayments()).thenReturn(Result.success(listOf(payment))) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any())).thenReturn(record) - val firstRepo = paymentProofRepo() - - firstRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - firstRepo.associateLightningPayment(request, PAYMENT_HASH).getOrThrow() - assertNull(storedProofs.single().proofData) - - paymentProofRepo().reconcile() - - verify(lightningRepo).getPayments() - val proofCaptor = argumentCaptor() - verify(paykitSdkService).submitPaymentProof( - counterparty = any(), - counterpartyReceiverPath = any(), - paymentRequestId = any(), - paymentEndpointIdentifier = any(), - proofJson = proofCaptor.capture(), - ) - assertEquals( - """{"data":"$PREIMAGE","type":"${PaykitPaymentProofKind.Lightning.type}"}""", - proofCaptor.firstValue, - ) - assertTrue(storedProofs.isEmpty()) - } - @Test fun `mismatched lightning preimage is not submitted`() = test { val request = paymentRequest(MethodId.Bolt11.rawValue) @@ -157,7 +121,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.completeLightningPayment(PAYMENT_HASH, "01".repeat(32)) assertNull(storedProofs.single().proofData) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) } @Test @@ -180,7 +144,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) assertTrue(storedProofs.isEmpty()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) } @Test @@ -193,7 +157,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.failLightningPayment(PAYMENT_HASH) assertTrue(storedProofs.isEmpty()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) } @Test @@ -202,10 +166,12 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + assertTrue(storedProofs.single().paymentStarted) repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) val endpointCaptor = argumentCaptor() @@ -216,6 +182,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { any(), endpointCaptor.capture(), proofCaptor.capture(), + isNull(), ) assertEquals(MethodId.P2wpkh.rawValue, endpointCaptor.firstValue) assertEquals( @@ -226,22 +193,102 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `lightning retry preserves earlier payment correlation`() = test { + fun `started onchain payment survives preparation cancellation`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val repo = paymentProofRepo() + + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.cancelPreparation(request) + + assertTrue(storedProofs.single().paymentStarted) + } + + @Test + fun `definite onchain failure clears started proof`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val repo = paymentProofRepo() + + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.failOnchainPayment(request) + + assertTrue(storedProofs.isEmpty()) + } + + @Test + fun `recurring proof includes the exact billing period`() = test { + val period = PaykitBillingPeriod( + startsAt = Instant.parse("2027-01-01T08:00:00Z"), + endsAt = Instant.parse("2027-02-01T08:00:00Z"), + ) + val request = paymentRequest(MethodId.Bolt11.rawValue, billingPeriod = period) val record = paymentRequestRecord() - val request = paymentRequest(MethodId.Bolt11.rawValue) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any())).thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() repo.associateLightningPayment(request, PAYMENT_HASH).getOrThrow() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, "aa".repeat(32)).getOrThrow() + repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) + + val periodCaptor = argumentCaptor() + verify(paykitSdkService).submitPaymentProof( + counterparty = any(), + counterpartyReceiverPath = any(), + paymentRequestId = any(), + paymentEndpointIdentifier = any(), + proofJson = any(), + billingPeriod = periodCaptor.capture(), + ) + assertEquals(period, periodCaptor.firstValue) + } + @Test + fun `proof from earlier billing period does not suppress recurring payment`() = test { + val currentPeriod = PaykitBillingPeriod( + startsAt = Instant.parse("2027-02-01T08:00:00Z"), + endsAt = Instant.parse("2027-03-01T08:00:00Z"), + ) + val existingProofJson = mock { + on { exportText() } doReturn """{"type":"${PaykitPaymentProofKind.Lightning.type}","data":"$PREIMAGE"}""" + } + val existingProof = mock { + on { billingPeriod } doReturn BillingPeriod( + startsAt = "2027-01-01T08:00:00.000Z", + endsAt = "2027-02-01T08:00:00.000Z", + ) + on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue + on { proof } doReturn existingProofJson + } + val record = paymentRequestRecord(listOf(existingProof)) + val request = paymentRequest(MethodId.Bolt11.rawValue, billingPeriod = currentPeriod) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any())).thenReturn(record) + val repo = paymentProofRepo() + + repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH).getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any()) - assertTrue(storedProofs.isEmpty()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), any()) + } + + @Test + fun `lightning retry is rejected while earlier payment is unresolved`() = test { + val request = paymentRequest(MethodId.Bolt11.rawValue) + val repo = paymentProofRepo() + + repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH).getOrThrow() + val retry = repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) + + assertTrue(retry.exceptionOrNull() is PaykitPaymentRequestError.OperationInProgress) + assertEquals(PAYMENT_HASH, storedProofs.single().paymentIdentifier) + + repo.failLightningPayment(PAYMENT_HASH) + repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + assertEquals(1, storedProofs.size) } @Test @@ -265,17 +312,37 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() shouldFailNextSave = true repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) assertTrue(storedProofs.isEmpty()) } + @Test + fun `completed onchain proof remains durable when persistence and submission initially fail`() = test { + val txid = "ab".repeat(32) + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val record = paymentRequestRecord() + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + .thenThrow(IllegalStateException("transient submission failure")) + val repo = paymentProofRepo() + + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + shouldFailNextSave = true + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) + + assertEquals(txid, storedProofs.single().proofData) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + } + @Test fun `onchain proof submits when prepared proof cannot be loaded`() = test { val txid = "ab".repeat(32) @@ -283,10 +350,11 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(endpoint) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, endpoint, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() shouldFailNextLoad = true repo.completeOnchainPayment(request, txid, endpoint) @@ -298,6 +366,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentRequestId = any(), paymentEndpointIdentifier = endpointCaptor.capture(), proofJson = proofCaptor.capture(), + billingPeriod = isNull(), ) assertEquals(endpoint, endpointCaptor.firstValue) assertEquals( @@ -307,16 +376,127 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.isEmpty()) } + @Test + fun `uncertain onchain payment is reconciled from its private destination`() = test { + val txid = "ab".repeat(32) + val record = paymentRequestRecord() + val request = paymentRequest(MethodId.P2wpkh.rawValue) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever(onchainPaymentLookup.transactionId(ONCHAIN_ADDRESS, request.amountSats, emptySet())).thenReturn(txid) + val repo = paymentProofRepo() + + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.reconcile() + + assertTrue(storedProofs.isEmpty()) + val proofCaptor = argumentCaptor() + verify(paykitSdkService).submitPaymentProof( + counterparty = eq(request.counterparty), + counterpartyReceiverPath = eq(request.counterpartyReceiverPath), + paymentRequestId = eq(request.paymentRequestId), + paymentEndpointIdentifier = eq(MethodId.P2wpkh.rawValue), + proofJson = proofCaptor.capture(), + billingPeriod = isNull(), + ) + assertTrue(proofCaptor.firstValue.contains(txid)) + } + + @Test + fun `uncertain onchain payment ignores transaction from before attempt`() = test { + val oldTransactionId = "ab".repeat(32) + val record = paymentRequestRecord() + val request = paymentRequest(MethodId.P2wpkh.rawValue) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(onchainPaymentLookup.existingTransactionIds(any(), any())).thenReturn(setOf(oldTransactionId)) + whenever( + onchainPaymentLookup.transactionId( + ONCHAIN_ADDRESS, + request.amountSats, + setOf(oldTransactionId), + ) + ).thenReturn(null) + val repo = paymentProofRepo() + + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.reconcile() + + assertEquals(setOf(oldTransactionId), storedProofs.single().onchainMatchingTransactionIdsBeforeAttempt) + assertNull(storedProofs.single().proofData) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + } + + @Test + fun `cancel preparation does not remove another identity proof`() = test { + val request = paymentRequest(MethodId.Bolt11.rawValue) + val otherIdentityProof = PendingPaykitPaymentProof( + identity = "pubky${"a".repeat(52)}", + requestId = request.id, + paymentEndpointIdentifier = MethodId.Bolt11.rawValue, + kind = PaykitPaymentProofKind.Lightning, + ) + storedProofs = listOf(otherIdentityProof) + val repo = paymentProofRepo() + + repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.cancelPreparation(request) + + assertEquals(listOf(otherIdentityProof), storedProofs) + } + + @Test + fun `subscription cancellation discards only unstarted preparation`() = test { + val period = PaykitBillingPeriod( + startsAt = Instant.parse("2027-01-01T08:00:00Z"), + endsAt = Instant.parse("2027-02-01T08:00:00Z"), + ) + val request = paymentRequest(MethodId.Bolt11.rawValue, billingPeriod = period) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + + val protectedRequestIds = repo.protectedRequestIdsForSubscriptionCancellation( + LOCAL_IDENTITY, + PaykitSubscriptionId(PAYMENT_REQUEST_ID, COUNTERPARTY, PaykitReceiverPaths.WALLET), + ).getOrThrow() + + assertTrue(protectedRequestIds.isEmpty()) + assertTrue(storedProofs.isEmpty()) + } + + @Test + fun `subscription cancellation preserves a started payment`() = test { + val period = PaykitBillingPeriod( + startsAt = Instant.parse("2027-01-01T08:00:00Z"), + endsAt = Instant.parse("2027-02-01T08:00:00Z"), + ) + val request = paymentRequest(MethodId.Bolt11.rawValue, billingPeriod = period) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH).getOrThrow() + + val protectedRequestIds = repo.protectedRequestIdsForSubscriptionCancellation( + LOCAL_IDENTITY, + PaykitSubscriptionId(PAYMENT_REQUEST_ID, COUNTERPARTY, PaykitReceiverPaths.WALLET), + ).getOrThrow() + + assertEquals(setOf(request.id), protectedRequestIds) + assertEquals(listOf(request.id), storedProofs.map { it.requestId }) + } + private fun paymentProofRepo() = PaykitPaymentProofRepo( ioDispatcher = testDispatcher, paykitSdkService = paykitSdkService, lightningRepo = lightningRepo, + onchainPaymentLookup = onchainPaymentLookup, store = store, ) private fun paymentRequest( endpoint: String, paymentRequestId: String = PAYMENT_REQUEST_ID, + billingPeriod: PaykitBillingPeriod? = null, ) = PaykitPaymentRequest( paymentRequestId = paymentRequestId, counterparty = COUNTERPARTY, @@ -325,6 +505,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { amountSats = 1_000uL, expiresAt = null, acceptedPaymentEndpointIdentifiers = listOf(endpoint), + billingPeriod = billingPeriod, ) private fun paymentRequestRecord(paymentProofs: List = emptyList()) = PaymentRequestRecord( diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt new file mode 100644 index 0000000000..6d44571000 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -0,0 +1,492 @@ +@file:OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) + +package to.bitkit.repositories + +import com.synonym.paykit.IdentityStatus +import com.synonym.paykit.LinkedPeerRecord +import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.PaymentReference +import com.synonym.paykit.PaymentRequestAmount +import com.synonym.paykit.PaymentRequestLifecycleState +import com.synonym.paykit.PaymentRequestLocalRole +import com.synonym.paykit.PaymentRequestRecord +import com.synonym.paykit.PaymentRequestRecurrence +import com.synonym.paykit.PaymentRequestTerms +import com.synonym.paykit.PrivateJsonObject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.services.PaykitReceiverPaths +import to.bitkit.services.PaykitSdkService +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispatcher()) { + private companion object { + const val PAYMENT_REQUEST_ID = "550e8400-e29b-41d4-a716-446655440000" + const val COUNTERPARTY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + const val LOCAL_IDENTITY = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + val START_TIME = Instant.parse("2027-01-15T08:00:00Z") + val PAYMENT_REFERENCE = mock { + on { exportText() } doReturn "invoice-123" + } + val METADATA = mock { + on { exportText() } doReturn """{"order":"123"}""" + } + } + + private val paykitSdkService = mock() + private val settingsStore = mock() + private val presentationStore = mock() + private val paymentProofStore = mock() + private val paymentProofRepo = mock() + private val notificationScheduler = mock() + private var schedulerOriginMillis = 0L + private val clock = object : Clock { + override fun now(): Instant = START_TIME.plus( + (testDispatcher.scheduler.currentTime - schedulerOriginMillis).milliseconds, + ) + } + private lateinit var sut: PaykitPaymentRequestRepo + + @Before + fun setUp() = test { + schedulerOriginMillis = testDispatcher.scheduler.currentTime + whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) + whenever(paykitSdkService.receivePrivateMessagesFromLinkedPeers()).thenReturn(emptyList()) + whenever(paykitSdkService.paymentRequests()).thenReturn(emptyList()) + whenever(settingsStore.isPaykitEnabled).thenReturn(flowOf(true)) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = true))) + whenever(presentationStore.load(LOCAL_IDENTITY)).thenReturn(emptySet()) + whenever( + presentationStore.loadSubscriptionState(any()) + ).thenReturn(PaykitSubscriptionPresentationState()) + whenever(paymentProofStore.completedRequestIdsAwaitingSubmission(LOCAL_IDENTITY)).thenReturn(emptySet()) + whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)).thenReturn(emptySet()) + whenever(paymentProofRepo.protectedRequestIdsForSubscriptionCancellation(any(), any())) + .thenReturn(Result.success(emptySet())) + sut = PaykitPaymentRequestRepo( + testDispatcher, + paykitSdkService, + settingsStore, + presentationStore, + paymentProofStore, + paymentProofRepo, + notificationScheduler, + clock, + ) + sut.activate(LOCAL_IDENTITY) + } + + @After + fun tearDown() = test { + sut.clear() + } + + @Test + fun `refresh maps active subscription and exposes current unpaid period`() = test { + val metadataText = """ + {"note":"Mobile plan","subscription":{"version":1,"description":"10 GB every month","benefits":["Roaming"]}} + """.trimIndent() + val metadata = mock { + on { exportText() } doReturn metadataText + } + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf( + paymentRequestRecord( + id = "recurring", + state = PaymentRequestLifecycleState.ACTIVE_RECURRING, + metadata = metadata, + ), + ), + ) + + sut.refresh(emptyList()).getOrThrow() + + val subscription = sut.subscriptions.value.single() + assertEquals("Mobile plan", subscription.note) + assertEquals("10 GB every month", subscription.metadata.description) + assertEquals(listOf("Roaming"), subscription.metadata.benefits) + val request = sut.pendingRequests.value.single() + assertEquals("recurring", request.paymentRequestId) + assertFalse(request.requiresAcceptance) + assertEquals(Instant.parse("2027-01-01T08:00:00Z"), request.billingPeriod?.startsAt) + assertEquals(Instant.parse("2027-02-01T08:00:00Z"), request.billingPeriod?.endsAt) + } + + @Test + fun `accepting subscription returns current period and preserves payment targets`() = test { + val proposal = paymentRequestRecord() + val active = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(proposal), listOf(active)) + whenever( + paykitSdkService.acceptPaymentRequest( + COUNTERPARTY, + PaykitReceiverPaths.SERVER, + PAYMENT_REQUEST_ID, + ) + ).thenReturn(active) + whenever(paykitSdkService.linkedPeers()).thenReturn( + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + ) + whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)) + .thenReturn(listOf(PaykitReceiverPaths.SERVER)) + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + sut.refresh(listOf(COUNTERPARTY)).getOrThrow() + + val subscription = sut.subscriptions.value.single() + val dueRequest = sut.accept(subscription).getOrThrow() + + assertEquals(Instant.parse("2027-01-01T08:00:00Z"), dueRequest?.billingPeriod?.startsAt) + assertEquals(listOf(COUNTERPARTY), sut.eligibleTargets.value.map { it.publicKey }) + verifyBlocking(presentationStore) { + saveSubscriptionState( + eq(LOCAL_IDENTITY), + argThat { subscription.id in acceptedAt }, + ) + } + } + + @Test + fun `accepting subscription rejects terms changed after review`() = test { + val reviewedRecord = paymentRequestRecord() + val changedRecord = paymentRequestRecord(amount = "0.002") + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(reviewedRecord), listOf(changedRecord)) + sut.refresh(emptyList()).getOrThrow() + val reviewedSubscription = sut.subscriptions.value.single() + sut.refresh(emptyList()).getOrThrow() + + val result = sut.accept(reviewedSubscription) + + assertTrue(result.exceptionOrNull() is PaykitPaymentRequestError.RequestUnavailable) + verifyBlocking(paykitSdkService, never()) { acceptPaymentRequest(any(), any(), any()) } + } + + @Test + fun `accepted subscription stays successful when its immediate refresh fails`() = test { + val proposal = paymentRequestRecord() + val active = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) + whenever(paykitSdkService.paymentRequests()) + .thenReturn(listOf(proposal)) + .thenThrow(IllegalStateException("refresh failed")) + whenever( + paykitSdkService.acceptPaymentRequest( + COUNTERPARTY, + PaykitReceiverPaths.SERVER, + PAYMENT_REQUEST_ID, + ) + ).thenReturn(active) + sut.refresh(emptyList()).getOrThrow() + + val dueRequest = sut.accept(sut.subscriptions.value.single()).getOrThrow() + + assertEquals(PaymentRequestLifecycleState.ACTIVE_RECURRING, sut.subscriptions.value.single().lifecycleState) + assertEquals(Instant.parse("2027-01-01T08:00:00Z"), dueRequest?.billingPeriod?.startsAt) + assertEquals(listOf(dueRequest), sut.pendingRequests.value) + } + + @Test + fun `dismissed subscription period stays out of queue after refresh`() = test { + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING)), + ) + sut.refresh(emptyList()).getOrThrow() + val request = sut.pendingRequests.value.single() + + assertTrue(sut.dismissSubscriptionPayment(request)) + assertTrue(sut.pendingRequests.value.isEmpty()) + + sut.refresh(emptyList()).getOrThrow() + + assertTrue(sut.pendingRequests.value.isEmpty()) + verifyBlocking(presentationStore) { + saveSubscriptionState(eq(LOCAL_IDENTITY), argThat { dismissedPaymentIds == setOf(request.id) }) + } + } + + @Test + fun `completed subscription payment awaiting proof submission is not offered again`() = test { + val requestId = PaykitPaymentRequestId( + paymentRequestId = PAYMENT_REQUEST_ID, + counterparty = COUNTERPARTY, + counterpartyReceiverPath = PaykitReceiverPaths.SERVER, + billingPeriodStartsAt = "2027-01-01T08:00:00Z", + ) + whenever(paymentProofStore.completedRequestIdsAwaitingSubmission(LOCAL_IDENTITY)) + .thenReturn(setOf(requestId)) + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING)), + ) + + sut.refresh(emptyList()).getOrThrow() + + assertTrue(sut.pendingRequests.value.isEmpty()) + assertEquals(requestId, sut.paymentRequestHistory.value.single().id) + assertEquals( + PaymentRequestLifecycleState.PROOF_SUBMITTED, + sut.paymentRequestHistory.value.single().lifecycleState, + ) + } + + @Test + fun `in flight subscription payment is neither offered nor marked paid`() = test { + val requestId = PaykitPaymentRequestId( + paymentRequestId = PAYMENT_REQUEST_ID, + counterparty = COUNTERPARTY, + counterpartyReceiverPath = PaykitReceiverPaths.SERVER, + billingPeriodStartsAt = "2027-01-01T08:00:00Z", + ) + whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)).thenReturn(setOf(requestId)) + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING)), + ) + + sut.refresh(emptyList()).getOrThrow() + + assertTrue(sut.pendingRequests.value.isEmpty()) + assertTrue(sut.paymentRequestHistory.value.isEmpty()) + } + + @Test + fun `subscription cannot be canceled after payment has started`() = test { + val requestId = PaykitPaymentRequestId( + paymentRequestId = PAYMENT_REQUEST_ID, + counterparty = COUNTERPARTY, + counterpartyReceiverPath = PaykitReceiverPaths.SERVER, + billingPeriodStartsAt = "2027-01-01T08:00:00Z", + ) + val active = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) + whenever(paymentProofRepo.protectedRequestIdsForSubscriptionCancellation(eq(LOCAL_IDENTITY), any())) + .thenReturn(Result.success(setOf(requestId))) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(active)) + sut.refresh(emptyList()).getOrThrow() + + val result = sut.cancel(sut.subscriptions.value.single()) + + assertTrue(result.exceptionOrNull() is PaykitPaymentRequestError.OperationInProgress) + assertEquals(1, sut.subscriptions.value.size) + verifyBlocking(paykitSdkService, never()) { cancelPaymentRequest(any(), any(), any(), anyOrNull()) } + } + + @Test + fun `subscription cancellation proceeds without a started payment`() = test { + val active = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) + val canceled = paymentRequestRecord(state = PaymentRequestLifecycleState.CANCELED) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(active), emptyList()) + whenever( + paykitSdkService.cancelPaymentRequest( + COUNTERPARTY, + PaykitReceiverPaths.SERVER, + PAYMENT_REQUEST_ID, + ) + ).thenReturn(canceled) + sut.refresh(emptyList()).getOrThrow() + + sut.cancel(sut.subscriptions.value.single()).getOrThrow() + + verifyBlocking(paykitSdkService) { + cancelPaymentRequest(COUNTERPARTY, PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID) + } + } + + @Test + fun `malformed expiry is rejected and unsupported payment details disable acceptance`() = test { + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf( + paymentRequestRecord(id = "malformed", expiresAt = "not-a-timestamp"), + paymentRequestRecord(id = "unsupported", endpoints = listOf("btc-unsupported-method")), + ), + ) + + sut.refresh(emptyList()).getOrThrow() + + val subscription = sut.subscriptions.value.single() + assertEquals("unsupported", subscription.paymentRequestId) + assertFalse(subscription.isProposalActionable(clock.now())) + assertEquals(listOf(subscription), sut.subscriptionProposals()) + } + + @Test + fun `presented subscription stays available without auto presenting after reactivation`() = test { + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf(paymentRequestRecord(id = "subscription")), + ) + sut.refresh(emptyList()).getOrThrow() + val subscription = sut.subscriptions.value.single() + + assertTrue(sut.markSubscriptionProposalPresented(subscription)) + assertTrue(sut.automaticSubscriptionProposals().isEmpty()) + verifyBlocking(presentationStore) { + saveSubscriptionState(eq(LOCAL_IDENTITY), argThat { presentedProposalIds == setOf(subscription.id) }) + } + + sut.clear() + whenever(presentationStore.loadSubscriptionState(LOCAL_IDENTITY)).thenReturn( + PaykitSubscriptionPresentationState(presentedProposalIds = setOf(subscription.id)), + ) + sut.activate(LOCAL_IDENTITY) + sut.refresh(emptyList()).getOrThrow() + + assertEquals(listOf(subscription), sut.subscriptionProposals()) + assertTrue(sut.automaticSubscriptionProposals().isEmpty()) + } + + @Test + fun `subscription proposal moves to expired at its deadline`() = test { + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf(paymentRequestRecord(expiresAt = clock.now().plus(10.seconds).toString())), + ) + sut.refresh(emptyList()).getOrThrow() + + advanceTimeBy(10_000) + runCurrent() + + assertEquals(PaymentRequestLifecycleState.PROPOSAL_EXPIRED, sut.subscriptions.value.single().lifecycleState) + assertTrue(sut.subscriptionProposals().isEmpty()) + } + + @Test + fun `subscription proposal moves to expired when its schedule ends`() = test { + val endingRecurrence = PaymentRequestRecurrence( + every = 1u, + unit = "month", + startsAt = "2027-01-01T08:00:00Z", + anchor = "2027-01-01T08:00:00Z", + endsAt = clock.now().plus(10.seconds).toString(), + ) + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf(paymentRequestRecord(recurrence = endingRecurrence)), + ) + sut.refresh(emptyList()).getOrThrow() + + advanceTimeBy(10_000) + runCurrent() + + assertEquals(PaymentRequestLifecycleState.PROPOSAL_EXPIRED, sut.subscriptions.value.single().lifecycleState) + assertTrue(sut.subscriptionProposals().isEmpty()) + } + + @Test + fun `ended subscription keeps its unpaid period available`() = test { + val subscriptionId = PaykitSubscriptionId(PAYMENT_REQUEST_ID, COUNTERPARTY, PaykitReceiverPaths.SERVER) + val endingRecurrence = PaymentRequestRecurrence( + every = 1u, + unit = "month", + startsAt = "2027-01-01T08:00:00Z", + anchor = "2027-01-01T08:00:00Z", + endsAt = "2027-01-10T08:00:00Z", + ) + sut.clear() + whenever(presentationStore.loadSubscriptionState(LOCAL_IDENTITY)).thenReturn( + PaykitSubscriptionPresentationState( + acceptedAt = mapOf(subscriptionId to Instant.parse("2027-01-01T08:00:00Z")), + ), + ) + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf( + paymentRequestRecord( + state = PaymentRequestLifecycleState.ACTIVE_RECURRING, + recurrence = endingRecurrence, + ), + ), + ) + sut.activate(LOCAL_IDENTITY) + + sut.refresh(emptyList()).getOrThrow() + + assertTrue(sut.subscriptions.value.single().isExpired(clock.now())) + assertEquals( + Instant.parse(requireNotNull(endingRecurrence.endsAt)), + sut.pendingRequests.value.single().billingPeriod?.endsAt, + ) + } + + @Suppress("LongParameterList") + private fun paymentRequestRecord( + id: String = PAYMENT_REQUEST_ID, + state: PaymentRequestLifecycleState = PaymentRequestLifecycleState.PROPOSED, + amount: String = "0.001", + expiresAt: String? = null, + endpoints: List = listOf(MethodId.Bolt11.rawValue), + metadata: PrivateJsonObject = METADATA, + recurrence: PaymentRequestRecurrence = this.recurrence, + ) = PaymentRequestRecord( + counterparty = COUNTERPARTY, + counterpartyReceiverPath = PaykitReceiverPaths.SERVER, + paymentRequestId = id, + localRole = PaymentRequestLocalRole.PAYER, + state = state, + proposalStreamItemId = 1uL, + proposalOutboundMessageId = null, + proposalOutboundStatus = null, + proposalEventId = "proposal-event", + terms = PaymentRequestTerms( + amount = PaymentRequestAmount(value = amount, asset = "btc"), + paymentReference = PAYMENT_REFERENCE, + proposalExpiresAt = expiresAt, + recurrence = recurrence, + acceptedPaymentEndpointIdentifiers = endpoints, + metadata = metadata, + ), + acceptedEventId = null, + acceptedOutboundStatus = null, + rejectedEventId = null, + rejectedOutboundStatus = null, + canceledEventId = null, + canceledOutboundStatus = null, + paymentProofs = emptyList(), + lastStreamItemId = 1uL, + lastOutboundMessageId = null, + lastOutboundStatus = null, + lastEventAt = clock.now().toString(), + invalidReason = null, + ) + private fun linkedPeer( + publicKey: String, + state: LinkedPeerState, + receiverPath: String, + ) = LinkedPeerRecord( + counterparty = publicKey, + counterpartyReceiverPath = receiverPath, + state = state, + lastSyncAt = null, + lastPrivateReceiveAt = null, + failureCount = 0u, + localRecoveryAttemptId = null, + localRecoveryMarkerCreatedAt = null, + localRecoveryMarkerLastError = null, + remoteRecoveryAttemptId = null, + remoteRecoveryMarkerObservedAt = null, + ) + + private val recurrence = PaymentRequestRecurrence( + every = 1u, + unit = "month", + startsAt = "2027-01-01T08:00:00Z", + anchor = "2027-01-01T08:00:00Z", + endsAt = null, + ) +} diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index d68ff3a5a1..48329d865a 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -10,6 +10,7 @@ import com.synonym.paykit.PaymentRequestAmount import com.synonym.paykit.PaymentRequestLifecycleState import com.synonym.paykit.PaymentRequestLocalRole import com.synonym.paykit.PaymentRequestRecord +import com.synonym.paykit.PaymentRequestRecurrence import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivateJsonObject import kotlinx.coroutines.CompletableDeferred @@ -66,6 +67,9 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { private val paykitSdkService = mock() private val settingsStore = mock() private val presentationStore = mock() + private val paymentProofStore = mock() + private val paymentProofRepo = mock() + private val subscriptionNotificationScheduler = mock() private var schedulerOriginMillis = 0L private val clock = object : Clock { override fun now(): Instant = START_TIME.plus( @@ -83,7 +87,23 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(settingsStore.isPaykitEnabled).thenReturn(flowOf(true)) whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = true))) whenever(presentationStore.load(LOCAL_IDENTITY)).thenReturn(emptySet()) - sut = PaykitPaymentRequestRepo(testDispatcher, paykitSdkService, settingsStore, presentationStore, clock) + whenever( + presentationStore.loadSubscriptionState(any()) + ).thenReturn(PaykitSubscriptionPresentationState()) + whenever(paymentProofStore.completedRequestIdsAwaitingSubmission(LOCAL_IDENTITY)).thenReturn(emptySet()) + whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)).thenReturn(emptySet()) + whenever(paymentProofRepo.protectedRequestIdsForSubscriptionCancellation(any(), any())) + .thenReturn(Result.success(emptySet())) + sut = PaykitPaymentRequestRepo( + testDispatcher, + paykitSdkService, + settingsStore, + presentationStore, + paymentProofStore, + paymentProofRepo, + subscriptionNotificationScheduler, + clock, + ) sut.activate(LOCAL_IDENTITY) } @@ -175,7 +195,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.refresh(emptyList()).getOrThrow() - assertEquals(listOf("incoming"), sut.pendingRequests.value.map { it.paymentRequestId }) + assertEquals(listOf("incoming", "accepted"), sut.pendingRequests.value.map { it.paymentRequestId }) assertEquals( setOf("incoming", "accepted", "rejected", "expired", "outgoing", "unsupported"), sut.paymentRequestHistory.value.map { it.paymentRequestId }.toSet(), @@ -520,6 +540,8 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { endpoints: List = listOf(MethodId.Bolt11.rawValue), counterparty: String = COUNTERPARTY, receiverPath: String = PaykitReceiverPaths.SERVER, + recurrence: PaymentRequestRecurrence? = null, + metadata: PrivateJsonObject = METADATA, ) = PaymentRequestRecord( counterparty = counterparty, counterpartyReceiverPath = receiverPath, @@ -534,9 +556,9 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { amount = PaymentRequestAmount(value = amount, asset = "btc"), paymentReference = PAYMENT_REFERENCE, proposalExpiresAt = expiresAt, - recurrence = null, + recurrence = recurrence, acceptedPaymentEndpointIdentifiers = endpoints, - metadata = METADATA, + metadata = metadata, ), acceptedEventId = null, acceptedOutboundStatus = null, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt new file mode 100644 index 0000000000..35adfe7174 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt @@ -0,0 +1,158 @@ +@file:OptIn(kotlin.time.ExperimentalTime::class) + +package to.bitkit.repositories + +import com.synonym.paykit.PaymentRequestLifecycleState +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +class PaykitSubscriptionTest { + @Test + fun `monthly recurrence returns to anchor day after a short month`() { + val recurrence = PaykitSubscriptionRecurrence( + every = 1, + unit = PaykitRecurrenceUnit.Month, + startsAt = Instant.parse("2027-01-31T08:00:00Z"), + anchor = Instant.parse("2027-01-31T08:00:00Z"), + endsAt = null, + ) + + val periods = recurrence.periodsThrough( + date = Instant.parse("2027-03-15T08:00:00Z"), + acceptedAt = Instant.parse("2027-01-31T08:00:00Z"), + ) + + assertEquals(2, periods.size) + assertEquals(Instant.parse("2027-02-28T08:00:00Z"), periods[0].endsAt) + assertEquals(Instant.parse("2027-03-31T08:00:00Z"), periods[1].endsAt) + } + + @Test + fun `recurrence uses the first anchor boundary after start`() { + val recurrence = PaykitSubscriptionRecurrence( + every = 1, + unit = PaykitRecurrenceUnit.Month, + startsAt = Instant.parse("2027-01-01T08:00:00Z"), + anchor = Instant.parse("2027-01-15T08:00:00Z"), + endsAt = null, + ) + + val period = recurrence.periodsThrough( + date = Instant.parse("2027-01-10T08:00:00Z"), + acceptedAt = Instant.parse("2027-01-01T08:00:00Z"), + ).first() + + assertEquals(Instant.parse("2027-01-01T08:00:00Z"), period.startsAt) + assertEquals(Instant.parse("2027-01-15T08:00:00Z"), period.endsAt) + } + + @Test + fun `recurrence returns consecutive upcoming periods`() { + val recurrence = PaykitSubscriptionRecurrence( + every = 1, + unit = PaykitRecurrenceUnit.Week, + startsAt = Instant.parse("2027-01-01T08:00:00Z"), + anchor = Instant.parse("2027-01-01T08:00:00Z"), + endsAt = null, + ) + + val periods = recurrence.upcomingPeriodsAfter( + date = Instant.parse("2027-01-02T08:00:00Z"), + limit = 3, + ) + + assertEquals( + listOf( + Instant.parse("2027-01-08T08:00:00Z"), + Instant.parse("2027-01-15T08:00:00Z"), + Instant.parse("2027-01-22T08:00:00Z"), + ), + periods.map { it.startsAt }, + ) + } + + @Test + fun `recurrence preserves nanosecond billing boundaries`() { + val recurrence = PaykitSubscriptionRecurrence( + every = 1, + unit = PaykitRecurrenceUnit.Day, + startsAt = Instant.parse("2027-01-01T08:00:00.123100Z"), + anchor = Instant.parse("2027-01-01T08:00:00.123900Z"), + endsAt = null, + ) + + val period = recurrence.periodsThrough( + date = Instant.parse("2027-01-01T08:00:01Z"), + acceptedAt = Instant.parse("2027-01-01T08:00:00Z"), + ).first() + + assertEquals("2027-01-01T08:00:00.123100Z", period.sdkValue.startsAt) + assertEquals("2027-01-01T08:00:00.123900Z", period.sdkValue.endsAt) + } + + @Test + fun `recurrence does not invent period when anchor search exceeds limit`() { + val recurrence = PaykitSubscriptionRecurrence( + every = 1, + unit = PaykitRecurrenceUnit.Day, + startsAt = Instant.parse("2027-01-01T08:00:00Z"), + anchor = Instant.parse("2077-01-01T08:00:00Z"), + endsAt = null, + ) + + val periods = recurrence.periodsThrough( + date = Instant.parse("2027-01-01T08:00:00Z"), + acceptedAt = Instant.parse("2027-01-01T08:00:00Z"), + ) + + assertTrue(periods.isEmpty()) + assertFalse(recurrence.canMaterializePeriods) + } + + @Test + fun `day week month and year are supported but minute and hour are not`() { + assertTrue(PaykitRecurrenceUnit.Day.isSupported) + assertTrue(PaykitRecurrenceUnit.Week.isSupported) + assertTrue(PaykitRecurrenceUnit.Month.isSupported) + assertTrue(PaykitRecurrenceUnit.Year.isSupported) + assertFalse(PaykitRecurrenceUnit.Minute.isSupported) + assertFalse(PaykitRecurrenceUnit.Hour.isSupported) + } + + @Test + fun `subscription payment matching includes counterparty and receiver path`() { + val recurrence = PaykitSubscriptionRecurrence( + every = 1, + unit = PaykitRecurrenceUnit.Month, + startsAt = Instant.parse("2027-01-01T08:00:00Z"), + anchor = Instant.parse("2027-01-01T08:00:00Z"), + endsAt = null, + ) + val subscription = PaykitSubscription( + paymentRequestId = "shared", + counterparty = "counterparty-a", + counterpartyReceiverPath = "bitkit/server", + amountValue = "0.001", + amountSats = 100_000uL, + note = null, + createdAt = null, + proposalExpiresAt = null, + recurrence = recurrence, + metadata = PaykitSubscriptionMetadata(null, emptyList()), + acceptedPaymentEndpointIdentifiers = listOf("bitcoin-lightning-bolt11"), + lifecycleState = PaymentRequestLifecycleState.ACTIVE_RECURRING, + paidPeriods = emptyList(), + ) + val request = subscription.requestsThrough( + date = Instant.parse("2027-01-15T08:00:00Z"), + acceptedAt = Instant.parse("2027-01-01T08:00:00Z"), + ).single() + + assertTrue(request.belongsTo(subscription)) + assertFalse(request.copy(counterparty = "counterparty-b").belongsTo(subscription)) + assertFalse(request.copy(counterpartyReceiverPath = "bitkit/wallet").belongsTo(subscription)) + } +} diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 0520bd1624..e1b1a84aa6 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -1193,7 +1193,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - verifyBlocking(paykitSdkService, times(4)) { + verifyBlocking(paykitSdkService, times(15)) { prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, 7uL) } } diff --git a/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt b/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt new file mode 100644 index 0000000000..af02d8632e --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt @@ -0,0 +1,80 @@ +@file:OptIn(kotlin.time.ExperimentalTime::class) + +package to.bitkit.ui.screens.subscriptions + +import com.synonym.paykit.PaymentRequestLifecycleState +import org.junit.Test +import to.bitkit.repositories.PaykitRecurrenceUnit +import to.bitkit.repositories.PaykitSubscription +import to.bitkit.repositories.PaykitSubscriptionMetadata +import to.bitkit.repositories.PaykitSubscriptionRecurrence +import java.time.ZoneOffset +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +class SubscriptionsScreenTest { + private val now = Instant.parse("2027-01-15T08:00:00Z") + + @Test + fun `next transition includes the next recurring period`() { + assertEquals( + Instant.parse("2027-01-22T08:00:00Z"), + nextSubscriptionTransition(listOf(subscription(PaykitRecurrenceUnit.Week)), now, ZoneOffset.UTC), + ) + } + + @Test + fun `next transition includes the next local month`() { + assertEquals( + Instant.parse("2027-02-01T00:00:00Z"), + nextSubscriptionTransition(listOf(subscription(PaykitRecurrenceUnit.Year)), now, ZoneOffset.UTC), + ) + } + + @Test + fun `terminal open ended subscription omits timing`() { + val terminal = subscription(PaykitRecurrenceUnit.Week).copy( + lifecycleState = PaymentRequestLifecycleState.CANCELED, + ) + + assertFalse(terminal.shouldShowTiming(now)) + assertTrue(subscription(PaykitRecurrenceUnit.Week).shouldShowTiming(now)) + } + + @Test + fun `only active open ended subscriptions can be canceled`() { + val openEnded = subscription(PaykitRecurrenceUnit.Week) + val fixedEnd = openEnded.copy( + recurrence = openEnded.recurrence.copy( + endsAt = Instant.parse("2027-01-22T08:00:00Z"), + ), + ) + + assertTrue(openEnded.canCancel(now)) + assertFalse(fixedEnd.canCancel(now)) + } + + private fun subscription(unit: PaykitRecurrenceUnit) = PaykitSubscription( + paymentRequestId = "subscription", + counterparty = "pubkypayee", + counterpartyReceiverPath = "bitkit/server", + amountValue = "0.001", + amountSats = 100_000u, + note = "Subscription", + createdAt = Instant.parse("2027-01-01T08:00:00Z"), + proposalExpiresAt = null, + recurrence = PaykitSubscriptionRecurrence( + every = 1, + unit = unit, + startsAt = Instant.parse("2027-01-01T08:00:00Z"), + anchor = Instant.parse("2027-01-01T08:00:00Z"), + endsAt = null, + ), + metadata = PaykitSubscriptionMetadata(description = null, benefits = emptyList()), + acceptedPaymentEndpointIdentifiers = listOf("btc-lightning-bolt11"), + lifecycleState = PaymentRequestLifecycleState.ACTIVE_RECURRING, + paidPeriods = emptyList(), + ) +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 2582d443fe..4a0cf1c080 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -12,6 +12,7 @@ import app.cash.turbine.test import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner +import com.synonym.paykit.PaymentRequestLifecycleState import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred @@ -34,6 +35,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.PaymentFailureReason import org.lightningdevkit.ldknode.TransactionDetails import org.mockito.kotlin.any @@ -85,6 +87,8 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.MethodId import to.bitkit.repositories.NodeEventUpdate +import to.bitkit.repositories.PaykitBillingPeriod +import to.bitkit.repositories.PaykitOnchainPaymentProofResolution import to.bitkit.repositories.PaykitPaymentProofKind import to.bitkit.repositories.PaykitPaymentProofRepo import to.bitkit.repositories.PaykitPaymentRequest @@ -93,6 +97,11 @@ import to.bitkit.repositories.PaykitPaymentRequestDraft import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaykitPaymentRequestTarget +import to.bitkit.repositories.PaykitRecurrenceUnit +import to.bitkit.repositories.PaykitSubscription +import to.bitkit.repositories.PaykitSubscriptionId +import to.bitkit.repositories.PaykitSubscriptionMetadata +import to.bitkit.repositories.PaykitSubscriptionRecurrence import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.PendingPaymentResolution @@ -117,6 +126,7 @@ import to.bitkit.services.NodeServiceFgState import to.bitkit.test.BaseUnitTest import to.bitkit.ui.Routes import to.bitkit.ui.components.Sheet +import to.bitkit.ui.components.SubscriptionRoute import to.bitkit.ui.components.TimedSheetType import to.bitkit.ui.shared.toast.ToastQueueManager import to.bitkit.ui.sheets.SendRoute @@ -137,6 +147,7 @@ import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime +import kotlin.time.Instant @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) @RunWith(RobolectricTestRunner::class) @@ -194,6 +205,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val pubkyContactsLoadVersion = MutableStateFlow(0L) private val pendingPaykitPaymentRequests = MutableStateFlow>(emptyList()) private val paykitPaymentRequestHistory = MutableStateFlow>(emptyList()) + private val paykitSubscriptions = MutableStateFlow>(emptyList()) + private val onchainPaymentResolution = MutableStateFlow(null) private val surfacedPaykitPaymentRequestIds = mutableSetOf() private val testPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" @@ -254,6 +267,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(pubkyRepo.contactsLoadVersion).thenReturn(pubkyContactsLoadVersion) whenever(paykitPaymentRequestRepo.pendingRequests).thenReturn(pendingPaykitPaymentRequests) whenever(paykitPaymentRequestRepo.paymentRequestHistory).thenReturn(paykitPaymentRequestHistory) + whenever(paykitPaymentRequestRepo.subscriptions).thenReturn(paykitSubscriptions) + whenever(paykitPaymentRequestRepo.automaticSubscriptionProposals()).thenReturn(emptyList()) whenever(paykitPaymentRequestRepo.eligibleTargets).thenReturn(MutableStateFlow(emptyList())) whenever(paykitPaymentRequestRepo.isCreatingRequest).thenReturn(MutableStateFlow(false)) whenever(paykitPaymentRequestRepo.automaticPendingRequests()).thenAnswer { @@ -269,8 +284,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } whenever(paykitPaymentRequestRepo.isPending(any())).thenReturn(true) whenever(paykitPaymentRequestRepo.isProcessing(any())).thenReturn(false) + whenever(paykitPaymentProofRepo.onchainPaymentResolution).thenReturn(onchainPaymentResolution) whenever { paykitPaymentProofRepo.prepare(any(), any(), any()) }.thenReturn(Result.success(Unit)) whenever { paykitPaymentProofRepo.associateLightningPayment(any(), any()) }.thenReturn(Result.success(Unit)) + whenever { activityRepo.setContact(any(), any(), any()) }.thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.initialLinkBurstStarted).thenReturn(MutableSharedFlow()) whenever { privatePaykitRepo.prepareSavedContacts(any>(), any()) } .thenReturn(Result.success(Unit)) @@ -480,7 +497,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `manually reopened request waits for a newer private list and then opens`() = test { + fun `manually reopened request preserves tags while waiting for a newer private list`() = test { sut.setIsAuthenticated(true) val request = paymentRequest() val bolt11 = "lnbcrt1updatedmanualrequest" @@ -503,7 +520,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() sut.showPaymentRequests() - sut.openIncomingPaymentRequest(request.id) + sut.openIncomingPaymentRequestWithTags(request.id, listOf("Lunch")) advanceTimeBy(TRANSITION_SCREEN_MS) runCurrent() @@ -515,9 +532,129 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) assertEquals(request, activeContactPaymentContext()?.incomingPaymentRequest) + assertEquals(listOf("Lunch"), sut.sendUiState.value.selectedTags) verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) } + @Test + fun `due subscription request opens after the subscription sheet closes`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest().copy( + billingPeriod = PaykitBillingPeriod( + startsAt = Instant.parse("2026-08-24T00:00:00Z"), + endsAt = Instant.parse("2026-08-31T00:00:00Z"), + ) + ) + val bolt11 = "lnbcrt1duesubscriptionrequest" + whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( + Result.success( + PublicPaykitPaymentResult.Opened( + paymentRequest = bolt11, + privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 8uL), + ), + ), + ) + stubLightningScan(bolt11 = bolt11, amountSats = 0u) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + pendingPaykitPaymentRequests.value = listOf(request) + surfacedPaykitPaymentRequestIds += request.id + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + + val subscriptionId = PaykitSubscriptionId( + request.paymentRequestId, + request.counterparty, + request.counterpartyReceiverPath, + ) + sut.showSheet(Sheet.Subscription(SubscriptionRoute.Review(subscriptionId))) + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(request, activeContactPaymentContext()?.incomingPaymentRequest) + assertTrue(sut.sendUiState.value.isSubscriptionPayment) + verify(privatePaykitRepo).beginPaymentRequest(request) + } + + @Test + fun `subscription notification targets its exact billing period`() = test { + sut.setIsAuthenticated(true) + val otherRequest = paymentRequest().copy(paymentRequestId = "other-subscription") + val targetRequest = paymentRequest().copy( + paymentRequestId = "target-subscription", + billingPeriod = PaykitBillingPeriod( + startsAt = Instant.parse("2026-08-25T12:00:00Z"), + endsAt = Instant.parse("2026-09-01T12:00:00Z"), + ), + ) + whenever(paykitPaymentRequestRepo.refresh(emptyList())).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.beginPaymentRequest(targetRequest)).thenReturn( + Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) + ) + pendingPaykitPaymentRequests.value = listOf(otherRequest, targetRequest) + surfacedPaykitPaymentRequestIds += otherRequest.id + surfacedPaykitPaymentRequestIds += targetRequest.id + isPaykitEnabled.value = true + pubkyPublicKey.value = testPublicKey + runCurrent() + clearInvocations(privatePaykitRepo) + val pendingProposal = mock() + whenever(paykitPaymentRequestRepo.automaticSubscriptionProposals()).thenReturn(listOf(pendingProposal)) + + sut.onPaykitSubscriptionNotificationTapped(testPublicKey, targetRequest.id) + runCurrent() + + verify(privatePaykitRepo).beginPaymentRequest(targetRequest) + verify(privatePaykitRepo, never()).beginPaymentRequest(otherRequest) + } + + @Test + fun `subscription notification target survives initial identity activation`() = test { + sut.setIsAuthenticated(true) + val otherRequest = paymentRequest().copy(paymentRequestId = "other-subscription") + val targetRequest = paymentRequest().copy( + paymentRequestId = "target-subscription", + billingPeriod = PaykitBillingPeriod( + startsAt = Instant.parse("2026-08-25T12:00:00Z"), + endsAt = Instant.parse("2026-09-01T12:00:00Z"), + ), + ) + whenever(paykitPaymentRequestRepo.refresh(emptyList())).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.beginPaymentRequest(targetRequest)).thenReturn( + Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) + ) + pendingPaykitPaymentRequests.value = listOf(otherRequest, targetRequest) + surfacedPaykitPaymentRequestIds += otherRequest.id + surfacedPaykitPaymentRequestIds += targetRequest.id + isPaykitEnabled.value = true + + sut.onPaykitSubscriptionNotificationTapped(testPublicKey, targetRequest.id) + runCurrent() + pubkyContactsLoadVersion.value = 1L + pubkyPublicKey.value = testPublicKey + runCurrent() + + verify(privatePaykitRepo).beginPaymentRequest(targetRequest) + verify(privatePaykitRepo, never()).beginPaymentRequest(otherRequest) + } + + @Test + fun `subscription notification for another identity is ignored`() = test { + val targetRequest = paymentRequest().copy( + billingPeriod = PaykitBillingPeriod( + startsAt = Instant.parse("2026-08-25T12:00:00Z"), + endsAt = Instant.parse("2026-09-01T12:00:00Z"), + ), + ) + pubkyPublicKey.value = testPublicKey + + sut.onPaykitSubscriptionNotificationTapped("pubky${"a".repeat(52)}", targetRequest.id) + + verify(privatePaykitRepo, never()).beginPaymentRequest(targetRequest) + } + @Test fun `failed manual request presentation returns to the request queue`() = test { sut.setIsAuthenticated(true) @@ -2907,7 +3044,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - verify(paykitPaymentRequestRepo, never()).accept(any()) + verify(paykitPaymentRequestRepo, never()).accept(any()) verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) } @@ -2921,16 +3058,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) - whenever { - lightningRepo.sendOnChain( - address = address, - sats = request.amountSats, - speed = TransactionSpeed.Medium, - utxosToSpend = null, - isMaxAmount = false, - tags = emptyList(), - ) - }.thenReturn(Result.success("txid")) + stubSuccessfulOnchainSend(address, request.amountSats) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( SendUiState( @@ -2951,12 +3079,17 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentRequestRepo).accept(request) verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) verify(lightningRepo).sendOnChain( - address = address, - sats = request.amountSats, - speed = TransactionSpeed.Medium, - utxosToSpend = null, - isMaxAmount = false, - tags = emptyList(), + address = any(), + sats = any(), + speed = anyOrNull(), + utxosToSpend = anyOrNull(), + feeRates = anyOrNull(), + isTransfer = any(), + channelId = anyOrNull(), + isMaxAmount = any(), + tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), ) } @@ -3007,16 +3140,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val contactKey = "pubkycontact" val privateContext = PrivatePaykitPaymentContext("bitkit/wallet", 7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) - whenever { - lightningRepo.sendOnChain( - address = address, - sats = 1000u, - speed = TransactionSpeed.Medium, - utxosToSpend = null, - isMaxAmount = false, - tags = emptyList(), - ) - }.thenReturn(Result.success("txid")) + stubSuccessfulOnchainSend(address, 1000u) whenever(privatePaykitRepo.consumePrivatePaymentList(contactKey, privateContext)) .thenReturn(Result.success(Unit)) setActiveContactPaymentContext(contactKey, privateContext) @@ -3043,16 +3167,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) - whenever { - lightningRepo.sendOnChain( - address = address, - sats = request.amountSats, - speed = TransactionSpeed.Medium, - utxosToSpend = null, - isMaxAmount = false, - tags = emptyList(), - ) - }.thenReturn(Result.success("txid")) + stubSuccessfulOnchainSend(address, request.amountSats) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( SendUiState( @@ -3156,6 +3271,346 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } + @Test + fun `paid recurring request refreshes subscription state immediately`() = test { + val address = "bcrt1qrecurringpaymentrequest" + val request = paymentRequest().copy( + billingPeriod = PaykitBillingPeriod( + startsAt = Instant.parse("2026-08-24T00:00:00Z"), + endsAt = Instant.parse("2026-08-31T00:00:00Z"), + ) + ) + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + pubkyPublicKey.value = testPublicKey + enablePaykitUi() + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(emptyList())).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + stubSuccessfulOnchainSend(address, request.amountSats) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = address, + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + ), + ) + + confirmCurrentPayment() + + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) + verify(paykitPaymentRequestRepo).refresh(emptyList()) + } + + @Test + fun `initial subscription payment auto start is consumed once`() = test { + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + setSendState( + SendUiState( + amount = 1_000u, + isInitialSubscriptionPayment = true, + initialSubscriptionPaymentAutoStartPending = true, + ) + ) + + sut.setSendEvent(SendEvent.StartInitialSubscriptionPayment) + advanceUntilIdle() + + assertFalse(sut.sendUiState.value.initialSubscriptionPaymentAutoStartPending) + assertTrue(sut.sendUiState.value.shouldConfirmPay) + + sut.setSendEvent(SendEvent.ClearPayConfirmation) + advanceUntilIdle() + sut.setSendEvent(SendEvent.StartInitialSubscriptionPayment) + advanceUntilIdle() + + assertFalse(sut.sendUiState.value.shouldConfirmPay) + } + + @Test + fun `subscription acceptance pays a due period materialized during acceptance`() = test { + val subscription = subscriptionStartingAt(Clock.System.now() + 60.seconds) + val dueRequest = paymentRequest().copy( + lifecycleState = PaymentRequestLifecycleState.ACTIVE_RECURRING, + billingPeriod = PaykitBillingPeriod( + startsAt = Clock.System.now(), + endsAt = Clock.System.now() + 60.seconds, + ), + ) + paykitSubscriptions.value = listOf(subscription) + assertNull(subscription.paymentDueOnAcceptance(Clock.System.now())) + whenever(paykitPaymentRequestRepo.accept(subscription)).thenReturn(Result.success(dueRequest)) + whenever(privatePaykitRepo.beginPaymentRequestWaitingForUpdatedList(dueRequest)).thenReturn( + Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) + ) + + val startedPayment = sut.acceptSubscriptionAndStartPayment(subscription).getOrThrow() + + assertTrue(startedPayment) + verify(privatePaykitRepo).beginPaymentRequestWaitingForUpdatedList(dueRequest) + assertTrue(sut.currentSheet.value is Sheet.Send) + assertTrue(sut.sendUiState.value.isInitialSubscriptionPayment) + assertEquals(dueRequest.id, sut.sendUiState.value.incomingPaymentRequestId) + } + + @Test + fun `canceling initial subscription payment opens retry screen`() = test { + val request = paymentRequest() + setActiveContactPaymentContext( + testPublicKey, + incomingPaymentRequest = request, + isInitialSubscriptionPayment = true, + ) + setSendState( + SendUiState( + isPaymentRequest = true, + isInitialSubscriptionPayment = true, + incomingPaymentRequestId = request.id, + ) + ) + + sut.sendEffect.test { + sut.setSendEvent(SendEvent.CancelInitialSubscriptionPayment) + assertTrue(awaitItem() is SendEffect.NavigateToError) + } + } + + @Test + fun `onchain payment failure before send attempt cancels prepared proof`() = test { + val request = paymentRequest() + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + stubOnchainSend( + address = "bcrt1qpreflightfailure", + sats = request.amountSats, + result = Result.failure(IllegalStateException("preflight failed")), + invokeBeforeSendAttempt = false, + ) + setActiveContactPaymentContext( + testPublicKey, + incomingPaymentRequest = request, + isInitialSubscriptionPayment = true, + ) + setSendState( + SendUiState( + address = "bcrt1qpreflightfailure", + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + isInitialSubscriptionPayment = true, + ) + ) + + sut.sendEffect.test { + confirmCurrentPayment() + + assertTrue(awaitItem() is SendEffect.NavigateToError) + } + verify(paykitPaymentProofRepo).cancelPreparation(request) + verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) + } + + @Test + fun `definite onchain failure after send attempt allows proof retry`() = test { + val request = paymentRequest() + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + stubOnchainSend( + address = "bcrt1qdefinitefailure", + sats = request.amountSats, + result = Result.failure(NodeException.InvalidAddress("invalid address")), + ) + setActiveContactPaymentContext( + testPublicKey, + incomingPaymentRequest = request, + isInitialSubscriptionPayment = true, + ) + setSendState( + SendUiState( + address = "bcrt1qdefinitefailure", + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + isInitialSubscriptionPayment = true, + ) + ) + + sut.sendEffect.test { + confirmCurrentPayment() + + assertTrue(awaitItem() is SendEffect.NavigateToError) + } + verify(paykitPaymentProofRepo).failOnchainPayment(request) + } + + @Test + fun `uncertain onchain failure resolves the matching pending payment`() = test { + val request = paymentRequest() + pubkyPublicKey.value = testPublicKey + runCurrent() + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + stubOnchainSend( + address = "bcrt1quncertainfailure", + sats = request.amountSats, + result = Result.failure(IllegalStateException("outcome unknown")), + ) + setActiveContactPaymentContext( + testPublicKey, + incomingPaymentRequest = request, + isInitialSubscriptionPayment = true, + ) + setSendState( + SendUiState( + address = "bcrt1quncertainfailure", + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + isInitialSubscriptionPayment = true, + incomingPaymentRequestId = request.id, + ) + ) + + sut.sendEffect.test { + confirmCurrentPayment() + + val pendingRoute = SendRoute.Pending( + request.paymentRequestId, + request.amountSats.toLong(), + observeResolution = false, + ) + assertEquals( + SendEffect.NavigateToPending(pendingRoute.paymentHash, pendingRoute.amount, false), + awaitItem(), + ) + sut.showSheet(Sheet.Send(pendingRoute)) + + val transactionId = "ab".repeat(32) + onchainPaymentResolution.value = PaykitOnchainPaymentProofResolution( + testPublicKey, + request.id, + transactionId, + ) + assertEquals(SendEffect.PaymentSuccess, awaitItem()) + runCurrent() + assertEquals(transactionId, sut.successSendUiState.value.paymentHashOrTxId) + assertFalse(sut.successSendUiState.value.isLoadingDetails) + } + verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) + verify(paykitPaymentProofRepo, never()).cancelPreparation(any()) + } + + @Test + fun `cold onchain proof resolution restores contact correlation without opening success`() = test { + val request = paymentRequest() + val transactionId = "ef".repeat(32) + pubkyPublicKey.value = testPublicKey + runCurrent() + + onchainPaymentResolution.value = PaykitOnchainPaymentProofResolution( + testPublicKey, + request.id, + transactionId, + ) + runCurrent() + + verify(paykitPaymentProofRepo).consumeOnchainPaymentResolution(any()) + verify(activityRepo).setContact( + contactPublicKey = request.counterparty, + forPaymentId = transactionId, + syncLdkPayments = false, + ) + assertNull(sut.successSendUiState.value.paymentHashOrTxId) + } + + @Test + fun `unrelated uncertain onchain resolution does not hijack another send`() = test { + val request = paymentRequest() + pubkyPublicKey.value = testPublicKey + runCurrent() + val replacementRequest = paymentRequest().copy(paymentRequestId = "replacement-request") + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + stubOnchainSend( + address = "bcrt1quncertainreplacement", + sats = request.amountSats, + result = Result.failure(IllegalStateException("outcome unknown")), + ) + setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = request) + setSendState( + SendUiState( + address = "bcrt1quncertainreplacement", + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + incomingPaymentRequestId = request.id, + ) + ) + + sut.sendEffect.test { + confirmCurrentPayment() + awaitItem() + + setSendState( + SendUiState( + address = "bcrt1qreplacement", + amount = replacementRequest.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + incomingPaymentRequestId = replacementRequest.id, + ) + ) + sut.showSheet(Sheet.Send(SendRoute.Confirm)) + onchainPaymentResolution.value = PaykitOnchainPaymentProofResolution( + testPublicKey, + request.id, + "cd".repeat(32), + ) + runCurrent() + + expectNoEvents() + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertNull(sut.successSendUiState.value.paymentHashOrTxId) + } + } + + @Test + fun `post broadcast bookkeeping failure still completes payment proof`() = test { + val request = paymentRequest() + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + stubOnchainSend( + address = "bcrt1qbookkeepingfailure", + sats = request.amountSats, + result = Result.failure(IllegalStateException("activity persistence failed")), + broadcastTxId = "broadcast-txid", + ) + setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = request) + setSendState( + SendUiState( + address = "bcrt1qbookkeepingfailure", + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + ) + ) + + confirmCurrentPayment() + + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "broadcast-txid", MethodId.P2wpkh.rawValue) + verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) + } + @Test fun `incoming payment request is not accepted when private list consumption fails`() = test { val address = "bcrt1qpaymentrequest" @@ -3177,7 +3632,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify(paykitPaymentRequestRepo, never()).accept(any()) + verify(paykitPaymentRequestRepo, never()).accept(any()) verify(lightningRepo, never()).sendOnChain( address = any(), sats = any(), @@ -3188,6 +3643,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { channelId = anyOrNull(), isMaxAmount = any(), tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), ) } @@ -3209,7 +3666,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify(paykitPaymentRequestRepo, never()).accept(any()) + verify(paykitPaymentRequestRepo, never()).accept(any()) verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) } @@ -3231,7 +3688,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify(paykitPaymentRequestRepo, never()).accept(any()) + verify(paykitPaymentRequestRepo, never()).accept(any()) verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) verify(lightningRepo, never()).sendOnChain( address = any(), @@ -3243,6 +3700,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { channelId = anyOrNull(), isMaxAmount = any(), tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), ) } @@ -3264,7 +3723,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify(paykitPaymentRequestRepo, never()).accept(any()) + verify(paykitPaymentRequestRepo, never()).accept(any()) verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) } @@ -3288,7 +3747,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify(paykitPaymentRequestRepo, never()).accept(any()) + verify(paykitPaymentRequestRepo, never()).accept(any()) verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) verify(lightningRepo, never()).sendOnChain( address = any(), @@ -3300,6 +3759,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { channelId = anyOrNull(), isMaxAmount = any(), tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), ) } @@ -3307,16 +3768,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `non-contact onchain payment does not discard private endpoint`() = test { val address = "bcrt1qpublicpayment" balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) - whenever { - lightningRepo.sendOnChain( - address = address, - sats = 1000u, - speed = TransactionSpeed.Medium, - utxosToSpend = null, - isMaxAmount = false, - tags = emptyList(), - ) - }.thenReturn(Result.success("txid")) + stubSuccessfulOnchainSend(address, 1000u) setSendState( SendUiState( address = address, @@ -3638,6 +4090,40 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() } + private suspend fun stubSuccessfulOnchainSend(address: String, sats: ULong, txId: String = "txid") { + stubOnchainSend(address, sats, Result.success(txId), broadcastTxId = txId) + } + + private suspend fun stubOnchainSend( + address: String, + sats: ULong, + result: Result, + invokeBeforeSendAttempt: Boolean = true, + broadcastTxId: String? = null, + ) { + whenever { + lightningRepo.sendOnChain( + address = any(), + sats = any(), + speed = anyOrNull(), + utxosToSpend = anyOrNull(), + feeRates = anyOrNull(), + isTransfer = any(), + channelId = anyOrNull(), + isMaxAmount = any(), + tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), + ) + }.doSuspendableAnswer { invocation -> + kotlin.check(invocation.getArgument(0) == address) + kotlin.check(invocation.getArgument(1).toULong() == sats) + if (invokeBeforeSendAttempt) invocation.getArgument Unit>(9)() + if (broadcastTxId != null) invocation.getArgument Unit>(10)(broadcastTxId) + result + } + } + private fun enableQuickPay(thresholdSats: ULong) { settingsData.value = SettingsData(isQuickPayEnabled = true, quickPayAmount = 5) whenever(currencyRepo.convertFiatToSats(5.0, "USD")).thenReturn(Result.success(thresholdSats)) @@ -3771,10 +4257,19 @@ class AppViewModelSendFlowTest : BaseUnitTest() { publicKey: String, privatePaymentContext: PrivatePaykitPaymentContext? = null, incomingPaymentRequest: PaykitPaymentRequest? = null, + isInitialSubscriptionPayment: Boolean = false, ) { val field = AppViewModel::class.java.getDeclaredField("activeContactPaymentContext") field.isAccessible = true - field.set(sut, ContactPaymentContext(publicKey, privatePaymentContext, incomingPaymentRequest)) + field.set( + sut, + ContactPaymentContext( + publicKey, + privatePaymentContext, + incomingPaymentRequest, + isInitialSubscriptionPayment, + ), + ) } private fun activeContactPaymentContext(): ContactPaymentContext? { @@ -3838,6 +4333,28 @@ class AppViewModelSendFlowTest : BaseUnitTest() { acceptedPaymentEndpointIdentifiers = listOf(MethodId.Bolt11.rawValue, MethodId.P2wpkh.rawValue), ) + private fun subscriptionStartingAt(startsAt: Instant) = PaykitSubscription( + paymentRequestId = "subscription-id", + counterparty = testPublicKey, + counterpartyReceiverPath = "bitkit/server", + amountValue = "0.000025", + amountSats = 2_500uL, + note = "Weekly coffee", + createdAt = Clock.System.now(), + proposalExpiresAt = null, + recurrence = PaykitSubscriptionRecurrence( + every = 1, + unit = PaykitRecurrenceUnit.Week, + startsAt = startsAt, + anchor = startsAt, + endsAt = null, + ), + metadata = PaykitSubscriptionMetadata(description = null, benefits = emptyList()), + acceptedPaymentEndpointIdentifiers = listOf(MethodId.Bolt11.rawValue), + lifecycleState = PaymentRequestLifecycleState.PROPOSED, + paidPeriods = emptyList(), + ) + private fun paymentRequestCreation( request: PaykitPaymentRequest, wasPublishedToActiveState: Boolean = true, diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index a1f0f7d56c..7d6c36c230 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -447,6 +447,8 @@ class TransferViewModelTest : BaseUnitTest() { channelId = anyOrNull(), isMaxAmount = eq(true), tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), ) verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) } @@ -481,6 +483,8 @@ class TransferViewModelTest : BaseUnitTest() { channelId = anyOrNull(), isMaxAmount = eq(false), tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), ) verify(lightningRepo, never()).sendOnChain( address = any(), @@ -492,6 +496,8 @@ class TransferViewModelTest : BaseUnitTest() { channelId = anyOrNull(), isMaxAmount = eq(true), tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), ) verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) } @@ -521,6 +527,8 @@ class TransferViewModelTest : BaseUnitTest() { anyOrNull(), any(), any(), + any(), + any(), ), ).thenReturn(Result.failure(AppError("Coin selection failed"))) @@ -538,6 +546,8 @@ class TransferViewModelTest : BaseUnitTest() { channelId = anyOrNull(), isMaxAmount = eq(false), tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), ) verify(lightningRepo, never()).sendOnChain( address = any(), @@ -549,6 +559,8 @@ class TransferViewModelTest : BaseUnitTest() { channelId = anyOrNull(), isMaxAmount = eq(true), tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), ) verify(cacheStore, never()).addPaidOrder(any(), any()) } @@ -1744,6 +1756,8 @@ class TransferViewModelTest : BaseUnitTest() { anyOrNull(), any(), any(), + any(), + any(), ), ).thenReturn(Result.success(TXID)) } diff --git a/changelog.d/next/paykit-subscriptions.added.md b/changelog.d/next/paykit-subscriptions.added.md new file mode 100644 index 0000000000..71b66fce6d --- /dev/null +++ b/changelog.d/next/paykit-subscriptions.added.md @@ -0,0 +1 @@ +Bitkit can now review, manage, and pay recurring payment requests from Paykit contacts. From ea8964087a5dcacab884451db9b4ab18a094d362 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 26 Aug 2026 13:17:31 -0500 Subject: [PATCH 2/3] chore: name subscription changelog fragment --- changelog.d/next/{paykit-subscriptions.added.md => 1186.added.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{paykit-subscriptions.added.md => 1186.added.md} (100%) diff --git a/changelog.d/next/paykit-subscriptions.added.md b/changelog.d/next/1186.added.md similarity index 100% rename from changelog.d/next/paykit-subscriptions.added.md rename to changelog.d/next/1186.added.md From 2eecc75053a08d52bfb94478e99437a9bdde6075 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 27 Aug 2026 11:22:38 -0500 Subject: [PATCH 3/3] fix: polish subscription payments --- .../repositories/PaykitPaymentProofRepo.kt | 9 +- .../repositories/PaykitPaymentProofStore.kt | 6 +- .../repositories/PaykitPaymentRequestRepo.kt | 19 +- .../bitkit/repositories/PaykitSubscription.kt | 18 +- app/src/main/java/to/bitkit/ui/ContentView.kt | 7 +- .../IncomingPaymentRequestDetailsScreen.kt | 36 ++-- .../paymentrequests/PaymentRequestsScreen.kt | 32 +++- .../subscriptions/SubscriptionsScreen.kt | 23 ++- .../java/to/bitkit/ui/sheets/SendSheet.kt | 5 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 163 ++++++++++++------ ...aykitPaymentRequestRepoSubscriptionTest.kt | 37 +++- .../PaykitPaymentRequestRepoTest.kt | 44 ++++- .../subscriptions/SubscriptionsScreenTest.kt | 15 ++ .../viewmodels/AppViewModelSendFlowTest.kt | 51 ++++++ 14 files changed, 374 insertions(+), 91 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index b3e99bb2e4..74ab5d2b3e 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -32,7 +32,14 @@ import javax.inject.Singleton @Serializable enum class PaykitPaymentProofKind(val type: String) { Lightning("bitcoin-bolt11-preimage"), - Onchain("bitcoin-onchain-txid"), + Onchain("bitcoin-onchain-txid"); + + companion object { + fun fromPaymentEndpointIdentifier(identifier: String): PaykitPaymentProofKind? { + val method = MethodId.fromRawValue(identifier) ?: return null + return if (method.isOnchain) Onchain else Lightning + } + } } @Serializable diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt index b6041c94d7..bc340883f3 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt @@ -23,9 +23,11 @@ class PaykitPaymentProofStore @Inject constructor( return Json.decodeFromString(value).proofs } - fun completedRequestIdsAwaitingSubmission(identity: String): Set = load() + fun completedRequestProofKindsAwaitingSubmission( + identity: String, + ): Map = load() .filter { PubkyPublicKeyFormat.matches(it.identity, identity) && it.proofData != null } - .mapTo(mutableSetOf()) { it.requestId } + .associate { it.requestId to it.kind } fun inFlightRequestIds(identity: String): Set = load() .filter { PubkyPublicKeyFormat.matches(it.identity, identity) && it.paymentStarted } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 2f37e7ee30..42cda4c229 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -80,6 +80,7 @@ data class PaykitPaymentRequest( val direction: PaykitPaymentRequestDirection = PaykitPaymentRequestDirection.Incoming, val lifecycleState: PaymentRequestLifecycleState = PaymentRequestLifecycleState.PROPOSED, val billingPeriod: PaykitBillingPeriod? = null, + val paymentProofKind: PaykitPaymentProofKind? = null, ) { val id: PaykitPaymentRequestId get() = PaykitPaymentRequestId( @@ -509,9 +510,10 @@ class PaykitPaymentRequestRepo @Inject constructor( paykitSdkService.receivePrivateMessagesFromLinkedPeers().also(::logIntakeFailures) val now = clock.now() val records = paykitSdkService.paymentRequests() - val locallyCompletedRequestIds = expectedIdentity - ?.let(paymentProofStore::completedRequestIdsAwaitingSubmission) + val locallyCompletedProofKinds = expectedIdentity + ?.let(paymentProofStore::completedRequestProofKindsAwaitingSubmission) .orEmpty() + val locallyCompletedRequestIds = locallyCompletedProofKinds.keys val locallyInFlightRequestIds = expectedIdentity ?.let(paymentProofStore::inFlightRequestIds) .orEmpty() @@ -554,6 +556,7 @@ class PaykitPaymentRequestRepo @Inject constructor( request.lifecycleState == PaymentRequestLifecycleState.PROOF_SUBMITTED -> request request.id in locallyCompletedRequestIds -> request.copy( lifecycleState = PaymentRequestLifecycleState.PROOF_SUBMITTED, + paymentProofKind = locallyCompletedProofKinds[request.id], ) else -> null } @@ -562,7 +565,14 @@ class PaykitPaymentRequestRepo @Inject constructor( it.toPaykitPaymentRequest(PaymentRequestLocalRole.PAYER, now) }.filter { it.id !in locallyCompletedRequestIds && it.id !in locallyInFlightRequestIds } val incoming = (dueRequests + oneTimeIncoming).sortedBy { it.createdAt } - val history = (recurringHistory + records.mapNotNull { it.toPaykitPaymentRequestHistory(now) }) + val oneTimeHistory = records.mapNotNull { it.toPaykitPaymentRequestHistory(now) }.map { request -> + val proofKind = locallyCompletedProofKinds[request.id] ?: return@map request + request.copy( + lifecycleState = PaymentRequestLifecycleState.PROOF_SUBMITTED, + paymentProofKind = proofKind, + ) + } + val history = (recurringHistory + oneTimeHistory) .sortedByDescending { it.createdAt } val targets = expectedIdentity?.let { eligibleTargets(savedPublicKeys, it) }.orEmpty() if ( @@ -946,6 +956,9 @@ private fun PaymentRequestRecord.toPaykitPaymentRequest( } else { state }, + paymentProofKind = paymentProofs.lastOrNull()?.let { + PaykitPaymentProofKind.fromPaymentEndpointIdentifier(it.paymentEndpointIdentifier) + }, ) } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt index 1a9046ef72..12edeb2ec3 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt @@ -171,6 +171,7 @@ data class PaykitSubscription( val acceptedPaymentEndpointIdentifiers: List, val lifecycleState: PaymentRequestLifecycleState, val paidPeriods: List, + val paymentProofKinds: Map = emptyMap(), ) { val id: PaykitSubscriptionId get() = PaykitSubscriptionId(paymentRequestId, counterparty, counterpartyReceiverPath) @@ -222,6 +223,7 @@ data class PaykitSubscription( PaymentRequestLifecycleState.ACTIVE_RECURRING }, billingPeriod = period, + paymentProofKind = paymentProofKinds[period], ) } @@ -255,6 +257,14 @@ internal fun PaymentRequestRecord.toPaykitSubscription(): PaykitSubscription? { .filter { MethodId.fromRawValue(it) != null } .distinct() val metadataObject = requestTerms.metadata.subscriptionMetadata() + val payments = paymentProofs.mapNotNull { proof -> + val period = proof.billingPeriod ?: return@mapNotNull null + val periodStart = period.startsAt.parseInstant() ?: return@mapNotNull null + val periodEnd = period.endsAt.parseInstant() ?: return@mapNotNull null + val billingPeriod = PaykitBillingPeriod(periodStart, periodEnd).takeIf { periodStart < periodEnd } + ?: return@mapNotNull null + billingPeriod to PaykitPaymentProofKind.fromPaymentEndpointIdentifier(proof.paymentEndpointIdentifier) + } return PaykitSubscription( paymentRequestId = paymentRequestId, counterparty = counterparty, @@ -274,12 +284,8 @@ internal fun PaymentRequestRecord.toPaykitSubscription(): PaykitSubscription? { metadata = metadataObject, acceptedPaymentEndpointIdentifiers = endpoints, lifecycleState = state, - paidPeriods = paymentProofs.mapNotNull { proof -> - val period = proof.billingPeriod ?: return@mapNotNull null - val periodStart = period.startsAt.parseInstant() ?: return@mapNotNull null - val periodEnd = period.endsAt.parseInstant() ?: return@mapNotNull null - PaykitBillingPeriod(periodStart, periodEnd).takeIf { periodStart < periodEnd } - }, + paidPeriods = payments.map { it.first }, + paymentProofKinds = payments.mapNotNull { (period, kind) -> kind?.let { period to it } }.toMap(), ) } diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index ff4cf12f4e..8c5a748689 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -464,6 +464,9 @@ fun ContentView( val showWidgets by settingsViewModel.showWidgets.collectAsStateWithLifecycle() val currentSheet by appViewModel.currentSheet.collectAsStateWithLifecycle() val isCreatingPaymentRequest by appViewModel.isCreatingPaymentRequest.collectAsStateWithLifecycle() + val isAcceptingSubscription by appViewModel.isAcceptingSubscription.collectAsStateWithLifecycle() + val isRetryingInitialSubscriptionPayment by + appViewModel.isRetryingInitialSubscriptionPayment.collectAsStateWithLifecycle() var homeWalletPageRequest by remember { mutableIntStateOf(0) } var homeWidgetsPageRequest by remember { mutableIntStateOf(0) } val navigateToHomeWallet = { @@ -489,7 +492,9 @@ fun ContentView( onDismiss = { appViewModel.hideSheet() }, visibilityKey = currentSheet, onVisible = { appViewModel.onSheetVisible(currentSheet) }, - dismissEnabled = !isCreatingPaymentRequest, + dismissEnabled = !isCreatingPaymentRequest && + !isAcceptingSubscription && + !isRetryingInitialSubscriptionPayment, sheetHandlePlacement = when (currentSheet) { is Sheet.Widgets -> SheetHandlePlacement.ContentOverlay else -> SheetHandlePlacement.ScaffoldSlot 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 d068923f5e..6124a413a2 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 @@ -29,6 +29,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.synonym.paykit.PaymentRequestLifecycleState import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.ext.UiDateStyle @@ -148,18 +149,7 @@ private fun IncomingPaymentRequestDetailsContent( text = "${request.detailsAmountPrefix()}$it".withAccent(accentColor = Colors.White64), ) FillWidth() - CircularIcon( - icon = painterResource( - if (request.direction == PaykitPaymentRequestDirection.Incoming) { - R.drawable.ic_received - } else { - R.drawable.ic_sent - } - ), - iconColor = Colors.Purple, - backgroundColor = Colors.Purple16, - size = 48.dp, - ) + PaymentRequestDetailsIcon(request) } } VerticalSpacer(24.dp) @@ -294,6 +284,28 @@ private fun PaymentRequestTags( private fun PaykitPaymentRequest.detailsAmountPrefix(): String = if (direction == PaykitPaymentRequestDirection.Incoming) "-" else "+" +@Composable +private fun PaymentRequestDetailsIcon(request: PaykitPaymentRequest) { + val isCompleted = request.lifecycleState == PaymentRequestLifecycleState.PROOF_SUBMITTED + val isIncomingRequest = request.direction == PaykitPaymentRequestDirection.Incoming + CircularIcon( + icon = painterResource( + if (isCompleted == isIncomingRequest) R.drawable.ic_sent else R.drawable.ic_received + ), + iconColor = when { + isCompleted -> request.paymentRailIconColor + isIncomingRequest -> Colors.Purple + else -> Colors.Brand + }, + backgroundColor = when { + isCompleted -> request.paymentRailBackgroundColor + isIncomingRequest -> Colors.Purple16 + else -> Colors.Brand16 + }, + size = 48.dp, + ) +} + @Composable private fun RequestDetailCell( title: String, 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 e6e1bfae74..96dac46f51 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 @@ -45,6 +45,7 @@ import to.bitkit.R import to.bitkit.ext.UiDateStyle import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.repositories.PaykitPaymentProofKind import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus import to.bitkit.repositories.PaykitPaymentRequestDirection @@ -513,13 +514,8 @@ internal fun PaymentRequestCard( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(16.dp), ) { - if (isOutgoingPayment) { - CircularIcon( - icon = painterResource(R.drawable.ic_sent), - iconColor = Colors.Brand, - backgroundColor = Colors.Brand16, - size = 40.dp, - ) + if (request.showsPaymentRailIcon(isOutgoingPayment)) { + PaymentRailIcon(request = request, paymentWasSent = request.paymentWasSent(isOutgoingPayment)) } else { PubkyContactAvatar(profile = displayContact, size = 40.dp) } @@ -610,6 +606,28 @@ private fun List.nameFor(request: PaykitPaymentRequest): Str private val PaykitPaymentRequest.lazyListKey: String get() = "$paymentRequestId|$counterparty|$counterpartyReceiverPath|${billingPeriod?.startsAt ?: ""}" +internal val PaykitPaymentRequest.paymentRailIconColor + get() = if (paymentProofKind == PaykitPaymentProofKind.Lightning) Colors.Purple else Colors.Brand + +internal val PaykitPaymentRequest.paymentRailBackgroundColor + get() = if (paymentProofKind == PaykitPaymentProofKind.Lightning) Colors.Purple16 else Colors.Brand16 + +private fun PaykitPaymentRequest.showsPaymentRailIcon(isOutgoingPayment: Boolean): Boolean = + isOutgoingPayment || lifecycleState == PaymentRequestLifecycleState.PROOF_SUBMITTED + +private fun PaykitPaymentRequest.paymentWasSent(isOutgoingPayment: Boolean): Boolean = + isOutgoingPayment || direction == PaykitPaymentRequestDirection.Incoming + +@Composable +private fun PaymentRailIcon(request: PaykitPaymentRequest, paymentWasSent: Boolean) { + CircularIcon( + icon = painterResource(if (paymentWasSent) R.drawable.ic_sent else R.drawable.ic_received), + iconColor = request.paymentRailIconColor, + backgroundColor = request.paymentRailBackgroundColor, + size = 40.dp, + ) +} + private val previewRequest = PaykitPaymentRequest( paymentRequestId = "payment-request", counterparty = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", 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 04fd9b0f6d..c265b36674 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 @@ -2,6 +2,7 @@ package to.bitkit.ui.screens.subscriptions +import androidx.annotation.RawRes import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -53,6 +54,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.ext.dateTimeFormatterOf +import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.safe @@ -546,6 +548,7 @@ fun SubscriptionSheet(appViewModel: AppViewModel, initialRoute: SubscriptionRout var previousRoute by remember(initialRoute) { mutableStateOf(null) } var isProcessing by remember(initialRoute) { mutableStateOf(false) } val subscriptions by appViewModel.subscriptions.collectAsStateWithLifecycle() + val isAccepting by appViewModel.isAcceptingSubscription.collectAsStateWithLifecycle() val subscription = subscriptions.firstOrNull { it.id == route.id } val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() val now = rememberSubscriptionNow(listOfNotNull(subscription)) @@ -582,8 +585,10 @@ fun SubscriptionSheet(appViewModel: AppViewModel, initialRoute: SubscriptionRout now = now, contact = contacts.contactFor(subscription), onDetails = { - previousRoute = route - route = SubscriptionRoute.Details(subscription.id) + if (!isAccepting) { + previousRoute = route + route = SubscriptionRoute.Details(subscription.id) + } }, onSubscribe = { isProcessing = true @@ -604,7 +609,10 @@ fun SubscriptionSheet(appViewModel: AppViewModel, initialRoute: SubscriptionRout ) }, ) - is SubscriptionRoute.Success -> SubscriptionSuccess(onClose = appViewModel::hideSheet) + is SubscriptionRoute.Success -> SubscriptionSuccess( + onClose = appViewModel::hideSheet, + paymentType = null, + ) is SubscriptionRoute.Details -> SubscriptionMoreInfo( subscription = subscription, contact = contacts.contactFor(subscription), @@ -753,6 +761,7 @@ private fun SubscriptionProviderCard( @Composable fun SubscriptionSuccess( onClose: () -> Unit, + paymentType: NewTransactionSheetType?, modifier: Modifier = Modifier, ) { Box( @@ -760,7 +769,9 @@ fun SubscriptionSuccess( .fillMaxSize() .navigationBarsPadding() ) { - val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.confetti_purple)) + val composition by rememberLottieComposition( + LottieCompositionSpec.RawRes(subscriptionConfettiResource(paymentType)) + ) LottieAnimation( composition = composition, contentScale = ContentScale.Crop, @@ -786,6 +797,10 @@ fun SubscriptionSuccess( } } +@RawRes +internal fun subscriptionConfettiResource(paymentType: NewTransactionSheetType?): Int = + if (paymentType == NewTransactionSheetType.ONCHAIN) R.raw.confetti_orange else R.raw.confetti_purple + @Composable private fun SubscriptionMoreInfo( subscription: PaykitSubscription, 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 358c9557b3..89c1a0b7d9 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -270,6 +270,7 @@ fun SendSheet( if (sendUiState.isInitialSubscriptionPayment) { SubscriptionSuccess( onClose = appViewModel::hideSheet, + paymentType = sendDetail.type, modifier = Modifier.gradientBackground(), ) } else { @@ -417,6 +418,8 @@ fun SendSheet( val route = it.toRoute() val sendUiState by appViewModel.sendUiState.collectAsStateWithLifecycle() val isRetrying by walletViewModel.isRetryingLightningPayment.collectAsStateWithLifecycle() + val isRetryingInitialSubscriptionPayment by + appViewModel.isRetryingInitialSubscriptionPayment.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() SendErrorScreen( title = if (sendUiState.isInitialSubscriptionPayment) { @@ -429,7 +432,7 @@ fun SendSheet( } else { route.message }, - isRetrying = isRetrying, + isRetrying = isRetrying || isRetryingInitialSubscriptionPayment, retryText = stringResource(R.string.subscriptions__retry_payment) .takeIf { sendUiState.isInitialSubscriptionPayment }, secondaryText = stringResource(R.string.wallet__payment_requests_not_now) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 4fbe2a38d8..719e6ad6bf 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -307,6 +307,10 @@ class AppViewModel @Inject constructor( val paymentRequestHistory = paykitPaymentRequestRepo.paymentRequestHistory val eligiblePaymentRequestTargets = paykitPaymentRequestRepo.eligibleTargets val isCreatingPaymentRequest = paykitPaymentRequestRepo.isCreatingRequest + private val _isAcceptingSubscription = MutableStateFlow(false) + val isAcceptingSubscription = _isAcceptingSubscription.asStateFlow() + private val _isRetryingInitialSubscriptionPayment = MutableStateFlow(false) + val isRetryingInitialSubscriptionPayment = _isRetryingInitialSubscriptionPayment.asStateFlow() val subscriptions = paykitPaymentRequestRepo.subscriptions val pubkyContacts = pubkyRepo.contacts private var sheetTransitionJob: Job? = null @@ -3906,6 +3910,17 @@ class AppViewModel @Inject constructor( } fun showSheet(sheetType: Sheet) { + val replacesInitialSubscriptionInPlace = _currentSheet.value is Sheet.Subscription && + sheetType is Sheet.Send && + _sendUiState.value.isInitialSubscriptionPayment + if (replacesInitialSubscriptionInPlace) { + sheetTransitionJob?.cancel() + sheetTransitionJob = null + receiveSheetContext = null + _currentSheet.update { sheetType } + return + } + val previousJob = sheetTransitionJob val nextJob = viewModelScope.launch(start = CoroutineStart.LAZY) { receiveSheetContext = null @@ -4190,47 +4205,60 @@ class AppViewModel @Inject constructor( suspend fun acceptSubscriptionAndStartPayment( displayedSubscription: PaykitSubscription, - ): Result = runSuspendCatching { - val subscription = subscription(displayedSubscription.id) - ?.takeIf { it == displayedSubscription } - ?: throw PaykitPaymentRequestError.RequestUnavailable - val acceptedDueRequest = paykitPaymentRequestRepo.accept(subscription).getOrThrow() - if (acceptedDueRequest == null) { - val accepted = subscription(displayedSubscription.id) - if (accepted?.lifecycleState != PaymentRequestLifecycleState.ACTIVE_RECURRING) { - throw PaykitPaymentRequestError.RequestUnavailable - } - return@runSuspendCatching false - } + ): Result { + if (!_isAcceptingSubscription.compareAndSet(false, true)) { + return Result.failure(PaykitPaymentRequestError.OperationInProgress).onFailure(::toast) + } + + return try { + runSuspendCatching { + val subscription = subscription(displayedSubscription.id) + ?.takeIf { it == displayedSubscription } + ?: throw PaykitPaymentRequestError.RequestUnavailable + val acceptedDueRequest = paykitPaymentRequestRepo.accept(subscription).getOrThrow() + if (acceptedDueRequest == null) { + val accepted = subscription(displayedSubscription.id) + if (accepted?.lifecycleState != PaymentRequestLifecycleState.ACTIVE_RECURRING) { + throw PaykitPaymentRequestError.RequestUnavailable + } + return@runSuspendCatching false + } - val resolution = privatePaykitRepo.beginPaymentRequestWaitingForUpdatedList(acceptedDueRequest) - .getOrElse { error -> - showInitialSubscriptionPaymentFailure(acceptedDueRequest, error) - return@runSuspendCatching true - } - if (resolution !is PublicPaykitPaymentResult.Opened) { - showInitialSubscriptionPaymentFailure( - acceptedDueRequest, - PaykitPaymentRequestError.RequestUnavailable, - ) - return@runSuspendCatching true - } - val scanJob = openContactPayment( - paymentRequest = resolution.paymentRequest, - publicKey = acceptedDueRequest.counterparty, - privatePaymentContext = resolution.privatePaymentContext, - incomingPaymentRequest = acceptedDueRequest, - isInitialSubscriptionPayment = true, - ) - scanJob?.join() - if (_currentSheet.value !is Sheet.Send) { - paykitPaymentRequestRepo.markPresented(acceptedDueRequest) - val error = PaykitPaymentRequestError.RequestUnavailable - val failure = error.toSendFailureDetails(context, _sendUiState.value.currentLightningPaymentRequest()) - showSheet(Sheet.Send(SendRoute.errorFromFailure(failure))) + val resolution = privatePaykitRepo.beginPaymentRequestWaitingForUpdatedList(acceptedDueRequest) + .getOrElse { error -> + showInitialSubscriptionPaymentFailure(acceptedDueRequest, error) + return@runSuspendCatching true + } + if (resolution !is PublicPaykitPaymentResult.Opened) { + showInitialSubscriptionPaymentFailure( + acceptedDueRequest, + PaykitPaymentRequestError.RequestUnavailable, + ) + return@runSuspendCatching true + } + val scanJob = openContactPayment( + paymentRequest = resolution.paymentRequest, + publicKey = acceptedDueRequest.counterparty, + privatePaymentContext = resolution.privatePaymentContext, + incomingPaymentRequest = acceptedDueRequest, + isInitialSubscriptionPayment = true, + ) + scanJob?.join() + if (_currentSheet.value !is Sheet.Send) { + paykitPaymentRequestRepo.markPresented(acceptedDueRequest) + val error = PaykitPaymentRequestError.RequestUnavailable + val failure = error.toSendFailureDetails( + context, + _sendUiState.value.currentLightningPaymentRequest() + ) + showSheet(Sheet.Send(SendRoute.errorFromFailure(failure))) + } + true + }.onFailure(::toast) + } finally { + _isAcceptingSubscription.update { false } } - true - }.onFailure(::toast) + } private suspend fun showInitialSubscriptionPaymentFailure( request: PaykitPaymentRequest, @@ -4250,13 +4278,12 @@ class AppViewModel @Inject constructor( incomingPaymentRequestId = request.id, ) paykitPaymentRequestRepo.markPresented(request) - showSheet( - Sheet.Send( - SendRoute.errorFromFailure( - error.toSendFailureDetails(context, paymentRequest = null) - ) - ) - ) + val failure = error.toSendFailureDetails(context, paymentRequest = null) + if (_currentSheet.value is Sheet.Send) { + setSendEffect(SendEffect.NavigateToError(failure)) + } else { + showSheet(Sheet.Send(SendRoute.errorFromFailure(failure))) + } } suspend fun cancelSubscription(id: PaykitSubscriptionId): Result { @@ -4298,8 +4325,9 @@ class AppViewModel @Inject constructor( } fun retryIncomingPaymentRequest(id: PaykitPaymentRequestId) { - if (_sendUiState.value.isInitialSubscriptionPayment) { - initialSubscriptionPaymentRequestIds += id + if (_sendUiState.value.isInitialSubscriptionPayment && _currentSheet.value is Sheet.Send) { + retryInitialSubscriptionPaymentInCurrentSheet(id, _sendUiState.value.selectedTags) + return } clearActiveContactPaymentContext() viewModelScope.launch { @@ -4308,6 +4336,45 @@ class AppViewModel @Inject constructor( } } + private fun retryInitialSubscriptionPaymentInCurrentSheet( + id: PaykitPaymentRequestId, + tags: ImmutableList, + ) { + if (!_isRetryingInitialSubscriptionPayment.compareAndSet(false, true)) { + toast(PaykitPaymentRequestError.OperationInProgress) + return + } + clearActiveContactPaymentContext() + viewModelScope.launch { + try { + refreshIncomingPaykitPaymentRequests() + val request = paykitPaymentRequestRepo.pendingRequest(id) ?: run { + toast(PaykitPaymentRequestError.RequestUnavailable) + return@launch + } + val resolution = privatePaykitRepo.beginPaymentRequestWaitingForUpdatedList(request).getOrElse { + showInitialSubscriptionPaymentFailure(request, it) + return@launch + } + if (resolution !is PublicPaykitPaymentResult.Opened) { + showInitialSubscriptionPaymentFailure(request, PaykitPaymentRequestError.RequestUnavailable) + return@launch + } + val scanJob = openContactPayment( + paymentRequest = resolution.paymentRequest, + publicKey = request.counterparty, + privatePaymentContext = resolution.privatePaymentContext, + incomingPaymentRequest = request, + isInitialSubscriptionPayment = true, + selectedTags = tags, + ) + scanJob?.join() + } finally { + _isRetryingInitialSubscriptionPayment.update { false } + } + } + } + suspend fun dismissIncomingPaymentRequest(request: PaykitPaymentRequest): Result { if (requestedPaymentRequestId == request.id) { return Result.failure(PaykitPaymentRequestError.OperationInProgress).onFailure(::toast) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index 6d44571000..eb805e1407 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -2,9 +2,11 @@ package to.bitkit.repositories +import com.synonym.paykit.BillingPeriod import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.PaymentProofRecord import com.synonym.paykit.PaymentReference import com.synonym.paykit.PaymentRequestAmount import com.synonym.paykit.PaymentRequestLifecycleState @@ -84,7 +86,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat whenever( presentationStore.loadSubscriptionState(any()) ).thenReturn(PaykitSubscriptionPresentationState()) - whenever(paymentProofStore.completedRequestIdsAwaitingSubmission(LOCAL_IDENTITY)).thenReturn(emptySet()) + whenever(paymentProofStore.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY)).thenReturn(emptyMap()) whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)).thenReturn(emptySet()) whenever(paymentProofRepo.protectedRequestIdsForSubscriptionCancellation(any(), any())) .thenReturn(Result.success(emptySet())) @@ -235,8 +237,8 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat counterpartyReceiverPath = PaykitReceiverPaths.SERVER, billingPeriodStartsAt = "2027-01-01T08:00:00Z", ) - whenever(paymentProofStore.completedRequestIdsAwaitingSubmission(LOCAL_IDENTITY)) - .thenReturn(setOf(requestId)) + whenever(paymentProofStore.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY)) + .thenReturn(mapOf(requestId to PaykitPaymentProofKind.Onchain)) whenever(paykitSdkService.paymentRequests()).thenReturn( listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING)), ) @@ -249,6 +251,32 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat PaymentRequestLifecycleState.PROOF_SUBMITTED, sut.paymentRequestHistory.value.single().lifecycleState, ) + assertEquals(PaykitPaymentProofKind.Onchain, sut.paymentRequestHistory.value.single().paymentProofKind) + } + + @Test + fun `completed subscription payment retains its SDK payment rail`() = test { + val proof = mock { + on { billingPeriod } doReturn BillingPeriod( + startsAt = "2027-01-01T08:00:00Z", + endsAt = "2027-02-01T08:00:00Z", + ) + on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue + } + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf( + paymentRequestRecord( + state = PaymentRequestLifecycleState.ACTIVE_RECURRING, + paymentProofs = listOf(proof), + ), + ), + ) + + sut.refresh(emptyList()).getOrThrow() + + val request = sut.paymentRequestHistory.value.single() + assertEquals(PaymentRequestLifecycleState.PROOF_SUBMITTED, request.lifecycleState) + assertEquals(PaykitPaymentProofKind.Lightning, request.paymentProofKind) } @Test @@ -433,6 +461,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat endpoints: List = listOf(MethodId.Bolt11.rawValue), metadata: PrivateJsonObject = METADATA, recurrence: PaymentRequestRecurrence = this.recurrence, + paymentProofs: List = emptyList(), ) = PaymentRequestRecord( counterparty = COUNTERPARTY, counterpartyReceiverPath = PaykitReceiverPaths.SERVER, @@ -457,7 +486,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat rejectedOutboundStatus = null, canceledEventId = null, canceledOutboundStatus = null, - paymentProofs = emptyList(), + paymentProofs = paymentProofs, lastStreamItemId = 1uL, lastOutboundMessageId = null, lastOutboundStatus = null, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 48329d865a..0d603bf4b7 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -5,6 +5,7 @@ package to.bitkit.repositories import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.PaymentProofRecord import com.synonym.paykit.PaymentReference import com.synonym.paykit.PaymentRequestAmount import com.synonym.paykit.PaymentRequestLifecycleState @@ -90,7 +91,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever( presentationStore.loadSubscriptionState(any()) ).thenReturn(PaykitSubscriptionPresentationState()) - whenever(paymentProofStore.completedRequestIdsAwaitingSubmission(LOCAL_IDENTITY)).thenReturn(emptySet()) + whenever(paymentProofStore.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY)).thenReturn(emptyMap()) whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)).thenReturn(emptySet()) whenever(paymentProofRepo.protectedRequestIdsForSubscriptionCancellation(any(), any())) .thenReturn(Result.success(emptySet())) @@ -210,6 +211,44 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) } + @Test + fun `completed one time payment retains its local payment rail`() = test { + val record = paymentRequestRecord() + val requestId = PaykitPaymentRequestId( + paymentRequestId = PAYMENT_REQUEST_ID, + counterparty = COUNTERPARTY, + counterpartyReceiverPath = PaykitReceiverPaths.SERVER, + ) + whenever(paymentProofStore.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY)) + .thenReturn(mapOf(requestId to PaykitPaymentProofKind.Onchain)) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + + sut.refresh(emptyList()).getOrThrow() + + val request = sut.paymentRequestHistory.value.single() + assertEquals(PaymentRequestLifecycleState.PROOF_SUBMITTED, request.lifecycleState) + assertEquals(PaykitPaymentProofKind.Onchain, request.paymentProofKind) + } + + @Test + fun `completed one time payment retains its SDK payment rail`() = test { + val proof = mock { + on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue + } + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf( + paymentRequestRecord( + state = PaymentRequestLifecycleState.PROOF_SUBMITTED, + paymentProofs = listOf(proof), + ), + ), + ) + + sut.refresh(emptyList()).getOrThrow() + + assertEquals(PaykitPaymentProofKind.Lightning, sut.paymentRequestHistory.value.single().paymentProofKind) + } + @Test fun `pending request is removed exactly when it expires`() = test { whenever(paykitSdkService.paymentRequests()).thenReturn( @@ -542,6 +581,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { receiverPath: String = PaykitReceiverPaths.SERVER, recurrence: PaymentRequestRecurrence? = null, metadata: PrivateJsonObject = METADATA, + paymentProofs: List = emptyList(), ) = PaymentRequestRecord( counterparty = counterparty, counterpartyReceiverPath = receiverPath, @@ -566,7 +606,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { rejectedOutboundStatus = null, canceledEventId = null, canceledOutboundStatus = null, - paymentProofs = emptyList(), + paymentProofs = paymentProofs, lastStreamItemId = 1uL, lastOutboundMessageId = null, lastOutboundStatus = null, diff --git a/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt b/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt index af02d8632e..a9bf5f3d2b 100644 --- a/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt @@ -4,6 +4,8 @@ package to.bitkit.ui.screens.subscriptions import com.synonym.paykit.PaymentRequestLifecycleState import org.junit.Test +import to.bitkit.R +import to.bitkit.models.NewTransactionSheetType import to.bitkit.repositories.PaykitRecurrenceUnit import to.bitkit.repositories.PaykitSubscription import to.bitkit.repositories.PaykitSubscriptionMetadata @@ -56,6 +58,19 @@ class SubscriptionsScreenTest { assertFalse(fixedEnd.canCancel(now)) } + @Test + fun `subscription payment confetti follows the settled rail`() { + assertEquals( + R.raw.confetti_purple, + subscriptionConfettiResource(NewTransactionSheetType.LIGHTNING), + ) + assertEquals( + R.raw.confetti_orange, + subscriptionConfettiResource(NewTransactionSheetType.ONCHAIN), + ) + assertEquals(R.raw.confetti_purple, subscriptionConfettiResource(null)) + } + private fun subscription(unit: PaykitRecurrenceUnit) = PaykitSubscription( paymentRequestId = "subscription", counterparty = "pubkypayee", diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 4a0cf1c080..cd9bb3a2f9 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -63,6 +63,7 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.domain.commands.NotifyChannelReadyHandler import to.bitkit.domain.commands.NotifyPaymentReceived import to.bitkit.domain.commands.NotifyPaymentReceivedHandler +import to.bitkit.ext.toSendFailureDetails import to.bitkit.models.BalanceState import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.NewTransactionSheetDetails @@ -3347,6 +3348,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(privatePaykitRepo.beginPaymentRequestWaitingForUpdatedList(dueRequest)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) ) + sut.showSheet(Sheet.Subscription(SubscriptionRoute.Review(subscription.id))) + advanceUntilIdle() val startedPayment = sut.acceptSubscriptionAndStartPayment(subscription).getOrThrow() @@ -3355,6 +3358,54 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertTrue(sut.currentSheet.value is Sheet.Send) assertTrue(sut.sendUiState.value.isInitialSubscriptionPayment) assertEquals(dueRequest.id, sut.sendUiState.value.incomingPaymentRequestId) + assertFalse(sut.isAcceptingSubscription.value) + } + + @Test + fun `initial subscription send replaces review without hiding the sheet`() = test { + val subscription = subscriptionStartingAt(Clock.System.now()) + val destination = Sheet.Send(SendRoute.Confirm) + sut.showSheet(Sheet.Subscription(SubscriptionRoute.Review(subscription.id))) + advanceUntilIdle() + setSendState(SendUiState(isInitialSubscriptionPayment = true)) + + sut.showSheet(destination) + runCurrent() + + assertEquals(destination, sut.currentSheet.value) + } + + @Test + fun `initial subscription retry keeps the send sheet presented`() = test { + val request = paymentRequest() + pendingPaykitPaymentRequests.value = listOf(request) + whenever(paykitPaymentRequestRepo.refresh(emptyList())).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.beginPaymentRequestWaitingForUpdatedList(request)).thenReturn( + Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) + ) + val sendSheet = Sheet.Send( + SendRoute.errorFromFailure( + IllegalStateException("failed").toSendFailureDetails(context, paymentRequest = null) + ) + ) + setSendState( + SendUiState( + isPaymentRequest = true, + isSubscriptionPayment = true, + isInitialSubscriptionPayment = true, + incomingPaymentRequestId = request.id, + ) + ) + sut.showSheet(sendSheet) + advanceUntilIdle() + + sut.retryIncomingPaymentRequest(request.id) + runCurrent() + + assertTrue(sut.currentSheet.value is Sheet.Send) + advanceUntilIdle() + assertTrue(sut.currentSheet.value is Sheet.Send) + assertFalse(sut.isRetryingInitialSubscriptionPayment.value) } @Test