Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -1320,6 +1324,9 @@ private fun PaymentRequestRecord.parsePaykitPaymentRequest(
},
)
}
if (requiresActionableRequest && requestTerms.paymentDeadline != null) {
Comment thread
ovitrif marked this conversation as resolved.
return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.UnsupportedPaymentDeadline)
}
if (requestTerms.amount.asset != PaykitIssuerInterop.BITCOIN_ASSET) {
return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.UnsupportedAsset)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ data class PaykitSubscription(
val lifecycleState: PaymentRequestLifecycleState,
val paidPeriods: List<PaykitBillingPeriod>,
val paymentProofKinds: Map<PaykitBillingPeriod, PaykitPaymentProofKind> = emptyMap(),
val hasPaymentDeadline: Boolean = false,
) {
val id: PaykitSubscriptionId
get() = PaykitSubscriptionId(paymentRequestId, counterparty, counterpartyReceiverPath)
Expand All @@ -205,6 +206,7 @@ data class PaykitSubscription(

fun isProposalActionable(now: Instant): Boolean =
isProposalVisible(now) &&
!hasPaymentDeadline &&
Comment thread
ovitrif marked this conversation as resolved.
recurrence.unit.isSupported &&
recurrence.canMaterializePeriods &&
acceptedPaymentEndpointIdentifiers.isNotEmpty()
Expand Down Expand Up @@ -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<PaykitPaymentRequest> {
if (!isCreatedByUser) return emptyList()
Expand Down Expand Up @@ -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,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ class PaykitSubscriptionNotificationScheduler @Inject constructor(
.filter {
it.isPayer &&
it.isActive(now) &&
!it.hasPaymentDeadline &&
it.recurrence.unit.isSupported &&
acceptedAt(it) != null
}
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/to/bitkit/services/PaykitSdkService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,8 @@ class PaykitSdkService @Inject constructor(
)
},
acceptedPaymentEndpointIdentifiers = proposal.acceptedPaymentEndpointIdentifiers,
conversion = null,
paymentDeadline = null,
metadata = PrivateJsonObject(proposal.metadataJson),
)
handle.proposePaymentRequest(counterparty, counterpartyReceiverPath, terms)
Expand Down Expand Up @@ -776,6 +778,8 @@ class PaykitSdkService @Inject constructor(
PaymentProofSubmission(
billingPeriod = billingPeriod?.sdkValue,
paymentEndpointIdentifier = paymentEndpointIdentifier,
allowanceId = null,
conversionQuoteId = null,
proof = PrivateJsonObject(proofJson),
),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ class PaykitIssuerInteropTest {
proposalExpiresAt = null,
recurrence = null,
acceptedPaymentEndpointIdentifiers = endpointIdentifiers,
conversion = null,
paymentDeadline = null,
metadata = METADATA,
),
acceptedEventId = null,
Expand All @@ -136,6 +138,7 @@ class PaykitIssuerInteropTest {
rejectedOutboundStatus = null,
canceledEventId = null,
canceledOutboundStatus = null,
conversionQuotes = emptyList(),
paymentProofs = emptyList(),
lastStreamItemId = 1uL,
lastOutboundMessageId = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -822,6 +822,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) {
proposalExpiresAt = null,
recurrence = null,
acceptedPaymentEndpointIdentifiers = listOf(MethodId.Bolt11.rawValue),
conversion = null,
paymentDeadline = null,
metadata = mock<PrivateJsonObject>(),
),
acceptedEventId = null,
Expand All @@ -830,6 +832,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) {
rejectedOutboundStatus = null,
canceledEventId = null,
canceledOutboundStatus = null,
conversionQuotes = emptyList(),
paymentProofs = paymentProofs,
lastStreamItemId = 1uL,
lastOutboundMessageId = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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<PaymentProofRecord> {
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(
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -782,6 +833,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat
state: PaymentRequestLifecycleState = PaymentRequestLifecycleState.PROPOSED,
amount: String = "0.001",
expiresAt: String? = null,
paymentDeadline: PaymentDeadline? = null,
endpoints: List<String> = listOf(MethodId.Bolt11.rawValue),
metadata: PrivateJsonObject = METADATA,
recurrence: PaymentRequestRecurrence = this.recurrence,
Expand All @@ -802,6 +854,8 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat
proposalExpiresAt = expiresAt,
recurrence = recurrence,
acceptedPaymentEndpointIdentifiers = endpoints,
conversion = null,
paymentDeadline = paymentDeadline,
metadata = metadata,
),
acceptedEventId = null,
Expand All @@ -810,6 +864,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat
rejectedOutboundStatus = null,
canceledEventId = null,
canceledOutboundStatus = null,
conversionQuotes = emptyList(),
paymentProofs = paymentProofs,
lastStreamItemId = 1uL,
lastOutboundMessageId = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -821,6 +840,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) {
amount: String = "0.001",
asset: String = "btc",
expiresAt: String? = null,
paymentDeadline: PaymentDeadline? = null,
endpoints: List<String> = listOf(MethodId.Bolt11.rawValue),
counterparty: String = COUNTERPARTY,
receiverPath: String = PaykitReceiverPaths.SERVER,
Expand All @@ -843,6 +863,8 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) {
proposalExpiresAt = expiresAt,
recurrence = recurrence,
acceptedPaymentEndpointIdentifiers = endpoints,
conversion = null,
paymentDeadline = paymentDeadline,
metadata = metadata,
),
acceptedEventId = null,
Expand All @@ -851,6 +873,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) {
rejectedOutboundStatus = null,
canceledEventId = null,
canceledOutboundStatus = null,
conversionQuotes = emptyList(),
paymentProofs = paymentProofs,
lastStreamItemId = 1uL,
lastOutboundMessageId = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1365.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Keep Paykit requests with unsupported payment deadlines in history and explain why their subscriptions cannot be accepted.
3 changes: 3 additions & 0 deletions docs/paykit-issuer-interoperability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading