diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 26890757f7..888459dc10 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -97,6 +97,7 @@ data class PaykitPaymentRequest( RecurringRequest("recurring_request", shouldLogIncomingRejection = false), UnsupportedRecurrence("unsupported_recurrence"), UnsupportedAsset("unsupported_asset"), + UnsupportedPaymentDeadline("unsupported_payment_deadline"), InvalidAmount("invalid_amount"), AmountOutOfRange("amount_out_of_range"), NoSupportedEndpoint("no_supported_endpoint"), @@ -868,7 +869,7 @@ class PaykitPaymentRequestRepo @Inject constructor( .mapTo(mutableSetOf()) { it.id } val updatedDismissedPaymentIds = dismissedSubscriptionPaymentIds.intersect(activeRecurringRequestIds) val dueRequests = recurringRequestsBySubscription - .filterKeys { it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING } + .filterKeys { it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING && !it.hasPaymentDeadline } .values .flatten() .filter { @@ -1107,7 +1108,10 @@ class PaykitPaymentRequestRepo @Inject constructor( } val recurringRequests = requestsThroughAcceptance(subscription, now) - val unpaidRequests = if (subscription.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING) { + val unpaidRequests = if ( + subscription.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING && + !subscription.hasPaymentDeadline + ) { recurringRequests.filter { it.lifecycleState != PaymentRequestLifecycleState.PROOF_SUBMITTED } } else { emptyList() @@ -1320,6 +1324,9 @@ private fun PaymentRequestRecord.parsePaykitPaymentRequest( }, ) } + if (requiresActionableRequest && requestTerms.paymentDeadline != null) { + return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.UnsupportedPaymentDeadline) + } if (requestTerms.amount.asset != PaykitIssuerInterop.BITCOIN_ASSET) { return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.UnsupportedAsset) } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt index 414a63c52d..6e123cc896 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt @@ -188,6 +188,7 @@ data class PaykitSubscription( val lifecycleState: PaymentRequestLifecycleState, val paidPeriods: List, val paymentProofKinds: Map = emptyMap(), + val hasPaymentDeadline: Boolean = false, ) { val id: PaykitSubscriptionId get() = PaykitSubscriptionId(paymentRequestId, counterparty, counterpartyReceiverPath) @@ -205,6 +206,7 @@ data class PaykitSubscription( fun isProposalActionable(now: Instant): Boolean = isProposalVisible(now) && + !hasPaymentDeadline && recurrence.unit.isSupported && recurrence.canMaterializePeriods && acceptedPaymentEndpointIdentifiers.isNotEmpty() @@ -260,7 +262,8 @@ data class PaykitSubscription( } } - fun paymentDueOnAcceptance(now: Instant): PaykitPaymentRequest? = requestsThrough(now, now).firstOrNull() + fun paymentDueOnAcceptance(now: Instant): PaykitPaymentRequest? = + if (hasPaymentDeadline) null else requestsThrough(now, now).firstOrNull() fun receivedPaymentRequests(): List { if (!isCreatedByUser) return emptyList() @@ -338,6 +341,7 @@ internal fun PaymentRequestRecord.toPaykitSubscription( lifecycleState = state, paidPeriods = payments.map { it.first }.distinct(), paymentProofKinds = payments.mapNotNull { (period, kind) -> kind?.let { period to it } }.toMap(), + hasPaymentDeadline = requestTerms.paymentDeadline != null, ) } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt index 63d371cae2..3ec2129d68 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt @@ -69,6 +69,7 @@ class PaykitSubscriptionNotificationScheduler @Inject constructor( .filter { it.isPayer && it.isActive(now) && + !it.hasPaymentDeadline && it.recurrence.unit.isSupported && acceptedAt(it) != null } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index ecbfdffb84..e0919bd3c0 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -737,6 +737,8 @@ class PaykitSdkService @Inject constructor( ) }, acceptedPaymentEndpointIdentifiers = proposal.acceptedPaymentEndpointIdentifiers, + conversion = null, + paymentDeadline = null, metadata = PrivateJsonObject(proposal.metadataJson), ) handle.proposePaymentRequest(counterparty, counterpartyReceiverPath, terms) @@ -776,6 +778,8 @@ class PaykitSdkService @Inject constructor( PaymentProofSubmission( billingPeriod = billingPeriod?.sdkValue, paymentEndpointIdentifier = paymentEndpointIdentifier, + allowanceId = null, + conversionQuoteId = null, proof = PrivateJsonObject(proofJson), ), ) 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 c1b03d96d5..12a688b986 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 @@ -762,7 +762,7 @@ private fun SubscriptionReview( if (!subscription.recurrence.unit.isSupported) { VerticalSpacer(16.dp) BodyM(text = stringResource(R.string.subscriptions__unsupported_description), color = Colors.White64) - } else if (subscription.acceptedPaymentEndpointIdentifiers.isEmpty()) { + } else if (subscription.hasPaymentDeadline || subscription.acceptedPaymentEndpointIdentifiers.isEmpty()) { VerticalSpacer(16.dp) BodyM( text = stringResource(R.string.subscriptions__unsupported_payment_description), diff --git a/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt index 052b0136cf..9bafd350c0 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt @@ -128,6 +128,8 @@ class PaykitIssuerInteropTest { proposalExpiresAt = null, recurrence = null, acceptedPaymentEndpointIdentifiers = endpointIdentifiers, + conversion = null, + paymentDeadline = null, metadata = METADATA, ), acceptedEventId = null, @@ -136,6 +138,7 @@ class PaykitIssuerInteropTest { rejectedOutboundStatus = null, canceledEventId = null, canceledOutboundStatus = null, + conversionQuotes = emptyList(), paymentProofs = emptyList(), lastStreamItemId = 1uL, lastOutboundMessageId = null, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 06092a9246..9079e92502 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -822,6 +822,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { proposalExpiresAt = null, recurrence = null, acceptedPaymentEndpointIdentifiers = listOf(MethodId.Bolt11.rawValue), + conversion = null, + paymentDeadline = null, metadata = mock(), ), acceptedEventId = null, @@ -830,6 +832,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { rejectedOutboundStatus = null, canceledEventId = null, canceledOutboundStatus = null, + conversionQuotes = emptyList(), paymentProofs = paymentProofs, lastStreamItemId = 1uL, lastOutboundMessageId = null, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index a6ff85e9f5..0c3fffa065 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -6,6 +6,7 @@ import com.synonym.paykit.BillingPeriod import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.PaymentDeadline import com.synonym.paykit.PaymentProofRecord import com.synonym.paykit.PaymentReference import com.synonym.paykit.PaymentRequestAmount @@ -563,7 +564,12 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat whenever(paymentProofStore.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY)) .thenReturn(mapOf(requestId to PaykitPaymentProofKind.Onchain)) whenever(paykitSdkService.paymentRequests()).thenReturn( - listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING)), + listOf( + paymentRequestRecord( + state = PaymentRequestLifecycleState.ACTIVE_RECURRING, + paymentDeadline = PaymentDeadline.PeriodStart(3600uL), + ), + ), ) sut.refresh().getOrThrow() @@ -602,6 +608,40 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat assertEquals(PaykitPaymentProofKind.Lightning, request.paymentProofKind) } + @Test + fun `deadline subscriptions retain paid periods and cancellation without offering payments`() = test { + advanceTimeBy(32 * 24 * 60 * 60 * 1000L) + val proof = mock { + on { billingPeriod } doReturn BillingPeriod("2027-01-01T08:00:00Z", "2027-02-01T08:00:00Z") + on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue + } + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf(PaymentRequestLocalRole.PAYER, PaymentRequestLocalRole.PAYEE).map { role -> + paymentRequestRecord( + id = role.name, + role = role, + state = PaymentRequestLifecycleState.ACTIVE_RECURRING, + paymentDeadline = PaymentDeadline.PeriodStart(3600uL), + paymentProofs = listOf(proof), + ) + }, + ) + + sut.refresh().getOrThrow() + + assertTrue(sut.pendingRequests.value.isEmpty()) + assertEquals(2, sut.subscriptions.value.size) + sut.subscriptions.value.forEach { subscription -> + assertEquals(1, subscription.paidPeriods.size) + assertTrue(subscription.canCancel(clock.now())) + assertEquals(null, subscription.paymentDueOnAcceptance(clock.now())) + } + val paid = sut.paymentRequestHistory.value.single() + assertEquals(PaymentRequestLifecycleState.PROOF_SUBMITTED, paid.lifecycleState) + assertEquals(PaykitPaymentProofKind.Lightning, paid.paymentProofKind) + assertEquals(1, sut.subscriptions.value.single { it.isCreatedByUser }.receivedPaymentRequests().size) + } + @Test fun `in flight subscription payment is neither offered nor marked paid`() = test { val requestId = PaykitPaymentRequestId( @@ -644,8 +684,14 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat @Test fun `subscription cancellation proceeds without a started payment`() = test { - val active = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) - val canceled = paymentRequestRecord(state = PaymentRequestLifecycleState.CANCELED) + val active = paymentRequestRecord( + state = PaymentRequestLifecycleState.ACTIVE_RECURRING, + paymentDeadline = PaymentDeadline.PeriodStart(3600uL), + ) + val canceled = paymentRequestRecord( + state = PaymentRequestLifecycleState.CANCELED, + paymentDeadline = PaymentDeadline.PeriodStart(3600uL), + ) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(active), emptyList()) whenever( paykitSdkService.cancelPaymentRequest( @@ -668,16 +714,21 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat whenever(paykitSdkService.paymentRequests()).thenReturn( listOf( paymentRequestRecord(id = "malformed", expiresAt = "not-a-timestamp"), + paymentRequestRecord(id = "deadline", paymentDeadline = PaymentDeadline.PeriodStart(3600uL)), paymentRequestRecord(id = "unsupported", endpoints = listOf("btc-unsupported-method")), ), ) sut.refresh().getOrThrow() - val subscription = sut.subscriptions.value.single() - assertEquals("unsupported", subscription.paymentRequestId) - assertFalse(subscription.isProposalActionable(clock.now())) - assertEquals(listOf(subscription), sut.subscriptionProposals()) + val subscriptions = sut.subscriptions.value + assertEquals(setOf("deadline", "unsupported"), subscriptions.map { it.paymentRequestId }.toSet()) + assertTrue(subscriptions.none { it.isProposalActionable(clock.now()) }) + assertEquals(subscriptions, sut.subscriptionProposals()) + val deadlineSubscription = subscriptions.first { it.paymentRequestId == "deadline" } + assertEquals(null, deadlineSubscription.paymentDueOnAcceptance(clock.now())) + assertTrue(sut.accept(deadlineSubscription).exceptionOrNull() is PaykitPaymentRequestError.RequestUnavailable) + verifyBlocking(paykitSdkService, never()) { acceptPaymentRequest(any(), any(), any()) } } @Test @@ -782,6 +833,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat state: PaymentRequestLifecycleState = PaymentRequestLifecycleState.PROPOSED, amount: String = "0.001", expiresAt: String? = null, + paymentDeadline: PaymentDeadline? = null, endpoints: List = listOf(MethodId.Bolt11.rawValue), metadata: PrivateJsonObject = METADATA, recurrence: PaymentRequestRecurrence = this.recurrence, @@ -802,6 +854,8 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat proposalExpiresAt = expiresAt, recurrence = recurrence, acceptedPaymentEndpointIdentifiers = endpoints, + conversion = null, + paymentDeadline = paymentDeadline, metadata = metadata, ), acceptedEventId = null, @@ -810,6 +864,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat rejectedOutboundStatus = null, canceledEventId = null, canceledOutboundStatus = null, + conversionQuotes = emptyList(), paymentProofs = paymentProofs, lastStreamItemId = 1uL, lastOutboundMessageId = null, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 42699007fd..48eb466774 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.PaymentDeadline import com.synonym.paykit.PaymentProofRecord import com.synonym.paykit.PaymentReference import com.synonym.paykit.PaymentRequestAmount @@ -165,6 +166,8 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { PaykitPaymentRequest.ParseFailure.NonActionableState, paymentRequestRecord().copy(terms = null) to PaykitPaymentRequest.ParseFailure.MissingTerms, paymentRequestRecord(asset = "BTC") to PaykitPaymentRequest.ParseFailure.UnsupportedAsset, + paymentRequestRecord(paymentDeadline = PaymentDeadline.At(clock.now().plus(1.seconds).toString())) to + PaykitPaymentRequest.ParseFailure.UnsupportedPaymentDeadline, paymentRequestRecord(amount = "not-bitcoin") to PaykitPaymentRequest.ParseFailure.InvalidAmount, paymentRequestRecord(amount = "184467440737.09551615") to PaykitPaymentRequest.ParseFailure.AmountOutOfRange, @@ -298,14 +301,30 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentRequestRecord(id = "outgoing", role = PaymentRequestLocalRole.PAYEE), paymentRequestRecord(id = "unsupported", endpoints = listOf("btc-unsupported-method")), paymentRequestRecord(id = "recurring", state = PaymentRequestLifecycleState.ACTIVE_RECURRING), - ), + ) + listOf( + PaymentRequestLifecycleState.PROPOSED, + PaymentRequestLifecycleState.ACCEPTED, + PaymentRequestLifecycleState.PROOF_SUBMITTED, + PaymentRequestLifecycleState.CANCELED, + PaymentRequestLifecycleState.REJECTED, + ).map { state -> + paymentRequestRecord( + id = "deadline-$state", + state = state, + paymentDeadline = PaymentDeadline.At(clock.now().toString()), + ) + }, ) sut.refresh().getOrThrow() assertEquals(listOf("incoming", "accepted"), sut.pendingRequests.value.map { it.paymentRequestId }) assertEquals( - setOf("incoming", "accepted", "rejected", "expired", "outgoing", "unsupported"), + setOf( + "incoming", "accepted", "rejected", "expired", "outgoing", "unsupported", + "deadline-PROPOSED", "deadline-ACCEPTED", "deadline-PROOF_SUBMITTED", + "deadline-CANCELED", "deadline-REJECTED", + ), sut.paymentRequestHistory.value.map { it.paymentRequestId }.toSet(), ) assertEquals( @@ -821,6 +840,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { amount: String = "0.001", asset: String = "btc", expiresAt: String? = null, + paymentDeadline: PaymentDeadline? = null, endpoints: List = listOf(MethodId.Bolt11.rawValue), counterparty: String = COUNTERPARTY, receiverPath: String = PaykitReceiverPaths.SERVER, @@ -843,6 +863,8 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { proposalExpiresAt = expiresAt, recurrence = recurrence, acceptedPaymentEndpointIdentifiers = endpoints, + conversion = null, + paymentDeadline = paymentDeadline, metadata = metadata, ), acceptedEventId = null, @@ -851,6 +873,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { rejectedOutboundStatus = null, canceledEventId = null, canceledOutboundStatus = null, + conversionQuotes = emptyList(), paymentProofs = paymentProofs, lastStreamItemId = 1uL, lastOutboundMessageId = null, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt index d21feac42c..733256f7f0 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt @@ -11,10 +11,12 @@ import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -110,6 +112,19 @@ class PaykitSubscriptionNotificationSchedulerTest { verify(workClient).cancelUniqueWork(WORK_NAME) } + @Test + fun `deadline subscriptions do not schedule payment reminders`() { + sut.synchronize( + subscriptions = listOf(subscription().copy(hasPaymentDeadline = true)), + acceptedAt = { NOW }, + pendingRequestIds = emptySet(), + payerIdentity = PAYER_IDENTITY, + notificationsEnabled = true, + ) + + verify(workClient, never()).enqueueUniqueWork(any(), any(), any()) + } + @Test fun `disabling and canceling clear tagged work`() { sut.synchronize( diff --git a/changelog.d/next/1365.fixed.md b/changelog.d/next/1365.fixed.md new file mode 100644 index 0000000000..748763dd59 --- /dev/null +++ b/changelog.d/next/1365.fixed.md @@ -0,0 +1 @@ +Keep Paykit requests with unsupported payment deadlines in history and explain why their subscriptions cannot be accepted. diff --git a/docs/paykit-issuer-interoperability.md b/docs/paykit-issuer-interoperability.md index f2ce0b0982..1f30e14df5 100644 --- a/docs/paykit-issuer-interoperability.md +++ b/docs/paykit-issuer-interoperability.md @@ -13,10 +13,13 @@ An actionable request must satisfy all of these requirements: - The amount is a positive decimal Bitcoin value with at most eight significant fractional digits and no more than `18,446,744,073,709,551` satoshis. - The request is a one-time proposal: the local role is payer, lifecycle state is proposed, and recurrence is absent. - The proposal expiration is absent or is a valid future ISO 8601 timestamp. +- `paymentDeadline` is absent. Bitkit does not yet enforce actual-payment deadlines. - `acceptedPaymentEndpointIdentifiers` retains at least one identifier supported on the wallet's current network. Bitkit filters `acceptedPaymentEndpointIdentifiers` in issuer order, removes duplicates after their first occurrence, and drops unknown or wrong-network identifiers. The request remains actionable when at least one identifier survives. +Requests with a payment deadline remain visible in history but are unavailable for payment. This restriction is separate from proposal expiration, which controls acceptance. + ### Endpoint identifiers Lightning identifiers are chain-independent and are accepted on every network: diff --git a/docs/payment-requests.md b/docs/payment-requests.md index 622c103b3b..ca0192266d 100644 --- a/docs/payment-requests.md +++ b/docs/payment-requests.md @@ -21,7 +21,7 @@ parse successfully but cannot be opened. The parse reasons are `missing_local_role`, `outgoing_request`, `unsupported_local_role`, `non_actionable_state`, `missing_terms`, `recurring_request`, `unsupported_recurrence`, -`unsupported_asset`, `invalid_amount`, `amount_out_of_range`, `no_supported_endpoint`, +`unsupported_payment_deadline`, `unsupported_asset`, `invalid_amount`, `amount_out_of_range`, `no_supported_endpoint`, `invalid_expiration`, and `expired`. The resolution reasons are `no_supported_endpoint`, `endpoint_not_payable`, @@ -36,6 +36,10 @@ and emits a privacy-safe warning with only the redacted counterparty. `unsupported_local_role` identifies an unknown role and emits a privacy-safe warning with only the redacted counterparty. +`unsupported_payment_deadline` excludes requests with an actual-payment deadline from payment +actions. Their history remains visible. Deadline-bearing subscriptions also retain paid periods +and existing cancellation controls, but cannot be accepted, paid, or scheduled for payment reminders. + ## Accessibility identifiers - Payment Requests screen: `PaymentRequestsScreen`. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 38dbf7a559..c3a4f08a16 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.5.18" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc55" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc56" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index a4f2c609b4..1a98604bf6 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -41,3 +41,16 @@ That run established the issuer shapes captured by the fixture: lowercase `btc`, - Saved-contact recipient: `ReviewContactRecipient` `android layout` can omit test tags applied to plain `Box` and `Column` containers. Use the raw UI Automator hierarchy when a documented container tag is not present in the formatted layout output. + +## Payment deadline history + +`payment-deadline-history.xml` covers rc56 requests with actual-payment deadlines. +Bitkit keeps their lifecycle and paid-period history, and subscription cancellation, +but does not accept them, offer payments, or schedule payment reminders. The journey +requires a controlled rc56 peer to prepare the accepted and paid records; repository +tests cover these states without sending funds. On both platforms, payment-history rows +show notes or dates rather than lifecycle labels, and active subscriptions are opened +from Overview. The journeys therefore record each fixture's payment request id, check +its full row identifier, and include the required back and tab transitions. The accepted +subscription must have no end date so cancellation is available. The proposal review +must explain that its payment details are unsupported and offer no Subscribe control. diff --git a/journeys/payment-requests/payment-deadline-history.xml b/journeys/payment-requests/payment-deadline-history.xml new file mode 100644 index 0000000000..abe0dba6f3 --- /dev/null +++ b/journeys/payment-requests/payment-deadline-history.xml @@ -0,0 +1,37 @@ + + + Requests with actual-payment deadlines are visible but cannot be paid by this version of Bitkit. + Use a disposable Paykit test identity linked to a controlled rc56 peer. Prepare one-time BTC + requests with deadlines in proposed, accepted, rejected, canceled and proof-submitted states, + plus an incoming, accepted monthly BTC subscription with no end date, a period-start deadline, + one paid period and one unpaid period. Acceptance and proof submission must be prepared through the controlled + client because Bitkit intentionally cannot accept these terms. Use valid regtest payment proofs. + Do not reset or replace a funded wallet to prepare this fixture. Record the payment request id + for each one-time state because history rows show notes or dates instead of status labels. + + + Open Subscriptions and select the Payments tab after the controlled peer's records have synchronized + Verify the proposed row (id "PaymentRequestRow-<proposed-payment-request-id>") is visible + Verify the accepted row (id "PaymentRequestRow-<accepted-payment-request-id>") is visible + Verify the rejected row (id "PaymentRequestRow-<rejected-payment-request-id>") is visible + Verify the canceled row (id "PaymentRequestRow-<canceled-payment-request-id>") is visible + Verify the completed row (id "PaymentRequestRow-<completed-payment-request-id>") is visible + Open proposed row (id "PaymentRequestRow-<proposed-payment-request-id>") and verify no Pay action appears + Return to Payments + Open accepted row (id "PaymentRequestRow-<accepted-payment-request-id>") and verify no Pay action appears + Return to Payments + Select the Overview tab + Open the active subscription and verify its paid period remains visible and Cancel is available + Return to Subscriptions + Select the Payments tab + Verify the unpaid period is not offered for payment in the Payments tab or pending-request sheet + Have the controlled peer send a new subscription proposal with a period-start deadline + Select the Overview tab after the proposal has synchronized + Open that proposal and verify it explains that its payment details are not supported yet + Verify no Subscribe swipe control appears + Dismiss the proposal to return to Overview + Open the accepted subscription + Cancel the accepted subscription and verify its paid-period history remains accessible + Restart Bitkit and verify the one-time and subscription payment history is still visible + +