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 @@ -95,6 +95,7 @@ data class PaykitPaymentRequest(
val paymentProofKind: PaykitPaymentProofKind? = null,
val paymentDeadlineAt: Instant? = null,
val isPaymentInFlight: Boolean = false,
val proposalStreamItemId: ULong? = null,
) {
enum class ParseFailure(
val logValue: String,
Expand Down Expand Up @@ -987,7 +988,12 @@ class PaykitPaymentRequestRepo @Inject constructor(
}
}
} else {
val proposalStreamItemId = request.proposalStreamItemId
?: throw PaykitPaymentRequestError.RequestUnavailable
updateRequest(request, PaymentRequestLifecycleState.ACCEPTED) {
if (it.proposalStreamItemId != proposalStreamItemId) {
throw PaykitPaymentRequestError.RequestUnavailable
}
val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable
val generation = stateGeneration.get()
ensurePaymentAllowed(it, forExecution = false).getOrThrow()
Expand All @@ -999,6 +1005,7 @@ class PaykitPaymentRequestRepo @Inject constructor(
paykitSdkService.acceptPaymentRequest(
counterparty = it.counterparty,
paymentRequestId = it.paymentRequestId,
expectedProposalStreamItemId = proposalStreamItemId,
)
}.onFailure { error ->
// Acceptance may already be committed. Reconcile before discarding its owner.
Expand Down Expand Up @@ -1063,10 +1070,13 @@ class PaykitPaymentRequestRepo @Inject constructor(
val current = _subscriptions.value.firstOrNull { it.id == subscription.id }
?.takeIf { it == subscription && it.isPayer && it.isProposalActionable(validationDate) }
?: throw PaykitPaymentRequestError.RequestUnavailable
val proposalStreamItemId = subscription.proposalStreamItemId
?: throw PaykitPaymentRequestError.RequestUnavailable
actionVersion++
val record = paykitSdkService.acceptPaymentRequest(
current.counterparty,
current.paymentRequestId,
proposalStreamItemId,
)
// Stored on real time, so a period the offset made due is not dropped when it turns Off.
val acceptanceDate = clock.now()
Expand Down Expand Up @@ -1856,6 +1866,7 @@ private fun PaymentRequestRecord.toPaykitPaymentRequest(
now: Instant,
) = PaykitPaymentRequest(
paymentRequestId = paymentRequestId,
proposalStreamItemId = proposalStreamItemId,
counterparty = counterparty,
amountValue = parsedTerms.terms.amount.value,
amountSats = parsedTerms.amountSats,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ data class PaykitSubscription(
val paidPeriods: List<PaykitBillingPeriod>,
val paymentProofKinds: Map<PaykitBillingPeriod, PaykitPaymentProofKind> = emptyMap(),
val hasPaymentDeadline: Boolean = false,
val proposalStreamItemId: ULong? = null,
) {
val id: PaykitSubscriptionId
get() = PaykitSubscriptionId(paymentRequestId, counterparty)
Expand Down Expand Up @@ -241,6 +242,7 @@ data class PaykitSubscription(
return recurrence.periodsThrough(date, acceptedAt).map { period ->
PaykitPaymentRequest(
paymentRequestId = paymentRequestId,
proposalStreamItemId = proposalStreamItemId,
counterparty = counterparty,
amountValue = amountValue,
amountSats = amountSats,
Expand Down Expand Up @@ -268,6 +270,7 @@ data class PaykitSubscription(
return paidPeriods.map { period ->
PaykitPaymentRequest(
paymentRequestId = paymentRequestId,
proposalStreamItemId = proposalStreamItemId,
counterparty = counterparty,
amountValue = amountValue,
amountSats = amountSats,
Expand Down Expand Up @@ -332,6 +335,7 @@ internal fun PaymentRequestRecord.toPaykitSubscription(
val payments = paymentProofs.mapNotNull { it.toSubscriptionPayment(recurrence) }
return PaykitSubscription(
paymentRequestId = paymentRequestId,
proposalStreamItemId = proposalStreamItemId,
counterparty = counterparty,
amountValue = requestTerms.amount.value,
amountSats = amountSats,
Expand Down
5 changes: 4 additions & 1 deletion app/src/main/java/to/bitkit/services/PaykitSdkService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1078,11 +1078,14 @@ class PaykitSdkService @Inject constructor(
suspend fun acceptPaymentRequest(
counterparty: String,
paymentRequestId: String,
expectedProposalStreamItemId: ULong,
): PaymentRequestRecord {
isSetup.await()
return operationLock.withLock(Priority.Interactive) {
withStateRevisionTracking { handle ->
completeSdkCall { handle.claimAndAcceptPaymentRequest(counterparty, paymentRequestId) }
completeSdkCall {
handle.claimAndAcceptPaymentRequest(counterparty, paymentRequestId, expectedProposalStreamItemId)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import to.bitkit.services.PaykitPaymentRequestProposalTerms
import to.bitkit.services.PaykitSdkService
import to.bitkit.test.BaseUnitTest
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.time.Clock
Expand Down Expand Up @@ -604,13 +605,14 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat

@Test
fun `accepting subscription returns current period and preserves payment targets`() = test {
val proposal = paymentRequestRecord()
val active = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING)
val proposal = paymentRequestRecord().copy(proposalStreamItemId = 41uL)
val active = proposal.copy(state = PaymentRequestLifecycleState.ACTIVE_RECURRING)
whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposal), listOf(active))
whenever(
paykitSdkService.acceptPaymentRequest(
COUNTERPARTY,
PAYMENT_REQUEST_ID,
41uL,
)
).thenReturn(active)
whenever(paykitSdkService.linkedPeers()).thenReturn(
Expand All @@ -637,6 +639,35 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat
}
}

@Test
fun `subscription acceptance rejects a replaced proposal with matching terms`() = test {
val original = paymentRequestRecord().copy(proposalStreamItemId = 41uL)
whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(original))
sut.refresh().getOrThrow()
val reviewed = sut.subscriptions.value.single()
whenever(paykitSdkService.allPaymentRequests(anyOrNull()))
.thenReturn(listOf(original.copy(proposalStreamItemId = 42uL)))
sut.refresh().getOrThrow()

assertFailsWith<PaykitPaymentRequestError.RequestUnavailable> { sut.accept(reviewed).getOrThrow() }

verify(paykitSdkService, never()).acceptPaymentRequest(any(), any(), any())
assertEquals(42uL, sut.subscriptions.value.single().proposalStreamItemId)
}

@Test
fun `subscription acceptance refuses a missing proposal stream item`() = test {
whenever(paykitSdkService.allPaymentRequests(anyOrNull()))
.thenReturn(listOf(paymentRequestRecord().copy(proposalStreamItemId = null)))
sut.refresh().getOrThrow()

assertFailsWith<PaykitPaymentRequestError.RequestUnavailable> {
sut.accept(sut.subscriptions.value.single()).getOrThrow()
}

verify(paykitSdkService, never()).acceptPaymentRequest(any(), any(), any())
}

@Test
fun `subscription clock change refreshes periods after an overlapping refresh`() = test {
val proposal = paymentRequestRecord()
Expand All @@ -646,6 +677,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat
paykitSdkService.acceptPaymentRequest(
COUNTERPARTY,
PAYMENT_REQUEST_ID,
1uL,
)
).thenReturn(active)
sut.refresh().getOrThrow()
Expand Down Expand Up @@ -686,6 +718,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat
paykitSdkService.acceptPaymentRequest(
COUNTERPARTY,
PAYMENT_REQUEST_ID,
1uL,
)
).thenReturn(active)
sut.refresh().getOrThrow()
Expand Down Expand Up @@ -718,6 +751,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat
paykitSdkService.acceptPaymentRequest(
COUNTERPARTY,
PAYMENT_REQUEST_ID,
1uL,
)
).thenReturn(active)
sut.refresh().getOrThrow()
Expand Down Expand Up @@ -756,7 +790,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat
val result = sut.accept(reviewedSubscription)

assertTrue(result.exceptionOrNull() is PaykitPaymentRequestError.RequestUnavailable)
verifyBlocking(paykitSdkService, never()) { acceptPaymentRequest(any(), any()) }
verifyBlocking(paykitSdkService, never()) { acceptPaymentRequest(any(), any(), any()) }
}

@Test
Expand All @@ -770,6 +804,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat
paykitSdkService.acceptPaymentRequest(
COUNTERPARTY,
PAYMENT_REQUEST_ID,
1uL,
)
).thenReturn(active)
sut.refresh().getOrThrow()
Expand Down Expand Up @@ -1212,7 +1247,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat
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()) }
verifyBlocking(paykitSdkService, never()) { acceptPaymentRequest(any(), any(), any()) }
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1172,12 +1172,13 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) {

@Test
fun `accept commits locally without waiting for peer delivery during payment`() = test {
val record = paymentRequestRecord()
val record = paymentRequestRecord().copy(proposalStreamItemId = 41uL)
whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record))
whenever(
paykitSdkService.acceptPaymentRequest(
COUNTERPARTY,
PAYMENT_REQUEST_ID,
41uL,
),
).thenReturn(record)
sut.refresh().getOrThrow()
Expand All @@ -1204,14 +1205,45 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) {
.thenReturn(listOf(record.copy(state = PaymentRequestLifecycleState.ACCEPTED)))
sut.refresh(PaykitPaymentRequestRefreshMode.FULL, Priority.Background).getOrThrow()
verify(paykitSdkService).processPendingPrivateMessages(Priority.Background)
verify(paykitSdkService, times(1)).acceptPaymentRequest(any(), any())
verify(paykitSdkService, times(1)).acceptPaymentRequest(COUNTERPARTY, PAYMENT_REQUEST_ID, 41uL)
}

@Test
fun `accept rejects a replacement proposal even when its terms match`() = test {
val original = paymentRequestRecord().copy(proposalStreamItemId = 41uL)
whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(original))
sut.refresh().getOrThrow()
val reviewed = sut.pendingRequests.value.single()
whenever(paykitSdkService.allPaymentRequests(anyOrNull()))
.thenReturn(listOf(original.copy(proposalStreamItemId = 42uL)))
sut.refresh().getOrThrow()

assertFailsWith<PaykitPaymentRequestError.RequestUnavailable> { sut.accept(reviewed).getOrThrow() }

verify(paykitSdkService, never()).acceptPaymentRequest(any(), any(), any())
verify(presentationStore, never()).addAcceptedOneTimeId(any(), any())
assertEquals(42uL, sut.pendingRequests.value.single().proposalStreamItemId)
}

@Test
fun `accept refuses a request without its proposal stream item`() = test {
whenever(paykitSdkService.allPaymentRequests(anyOrNull()))
.thenReturn(listOf(paymentRequestRecord().copy(proposalStreamItemId = null)))
sut.refresh().getOrThrow()

assertFailsWith<PaykitPaymentRequestError.RequestUnavailable> {
sut.accept(sut.pendingRequests.value.single()).getOrThrow()
}

verify(paykitSdkService, never()).acceptPaymentRequest(any(), any(), any())
verify(presentationStore, never()).addAcceptedOneTimeId(any(), any())
}

@Test
fun `deferred acceptance delivery is discarded when identity changes`() = test {
val record = paymentRequestRecord()
whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record))
whenever(paykitSdkService.acceptPaymentRequest(any(), any())).thenReturn(record)
whenever(paykitSdkService.acceptPaymentRequest(any(), any(), any())).thenReturn(record)
sut.refresh().getOrThrow()
sut.setPaymentSubmissionActive(true)
sut.accept(sut.pendingRequests.value.single()).getOrThrow()
Expand All @@ -1229,7 +1261,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) {
val proposed = paymentRequestRecord()
val accepted = proposed.copy(state = PaymentRequestLifecycleState.ACCEPTED)
whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed))
whenever(paykitSdkService.acceptPaymentRequest(any(), any())).thenReturn(accepted)
whenever(paykitSdkService.acceptPaymentRequest(any(), any(), any())).thenReturn(accepted)
sut.refresh().getOrThrow()
val request = sut.pendingRequests.value.single()
sut.accept(request).getOrThrow()
Expand All @@ -1242,7 +1274,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) {
sut.activate(LOCAL_IDENTITY)
sut.refresh().getOrThrow()
sut.accept(sut.pendingRequests.value.single()).getOrThrow()
verify(paykitSdkService, times(1)).acceptPaymentRequest(any(), any())
verify(paykitSdkService, times(1)).acceptPaymentRequest(any(), any(), any())
sut.ensurePaymentAllowed(request).getOrThrow()

sut.activate(SECOND_IDENTITY)
Expand Down Expand Up @@ -1492,7 +1524,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) {
whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed))
sut.refresh().getOrThrow()
assertTrue(sut.accept(sut.pendingRequests.value.single()).isFailure)
verify(paykitSdkService, never()).acceptPaymentRequest(any(), any())
verify(paykitSdkService, never()).acceptPaymentRequest(any(), any(), any())

whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(accepted))
sut.refresh().getOrThrow()
Expand All @@ -1513,7 +1545,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) {
sut.activate(LOCAL_IDENTITY)
val proposed = paymentRequestRecord()
whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed))
doSuspendableAnswer { throw error }.whenever(paykitSdkService).acceptPaymentRequest(any(), any())
doSuspendableAnswer { throw error }.whenever(paykitSdkService).acceptPaymentRequest(any(), any(), any())
sut.refresh().getOrThrow()
val request = sut.pendingRequests.value.single()
assertTrue(sut.accept(request).isFailure)
Expand All @@ -1536,7 +1568,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) {
fun `final authorization requires durable ownership regardless of snapshot lifecycle`() = test {
val proposed = paymentRequestRecord()
whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed))
whenever(paykitSdkService.acceptPaymentRequest(any(), any()))
whenever(paykitSdkService.acceptPaymentRequest(any(), any(), any()))
.thenReturn(proposed.copy(state = PaymentRequestLifecycleState.ACCEPTED))
val saving = CompletableDeferred<Unit>()
val saved = CompletableDeferred<Unit>()
Expand Down Expand Up @@ -1580,7 +1612,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) {
other.activate(LOCAL_IDENTITY)
var record = paymentRequestRecord()
whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenAnswer { listOf(record) }
whenever(paykitSdkService.acceptPaymentRequest(any(), any())).thenAnswer {
whenever(paykitSdkService.acceptPaymentRequest(any(), any(), any())).thenAnswer {
check(record.state == PaymentRequestLifecycleState.PROPOSED)
record = record.copy(state = PaymentRequestLifecycleState.ACCEPTED)
record
Expand Down Expand Up @@ -1612,7 +1644,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) {
val accepting = CompletableDeferred<Unit>()
val accepted = CompletableDeferred<Unit>()
whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed))
whenever(paykitSdkService.acceptPaymentRequest(any(), any())).doSuspendableAnswer {
whenever(paykitSdkService.acceptPaymentRequest(any(), any(), any())).doSuspendableAnswer {
accepting.complete(Unit)
accepted.await()
proposed.copy(state = PaymentRequestLifecycleState.ACCEPTED)
Expand Down Expand Up @@ -2548,7 +2580,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) {
sut.accept(request).getOrThrow()
}
verifyBlocking(paykitSdkService, never()) {
acceptPaymentRequest(COUNTERPARTY, PAYMENT_REQUEST_ID)
acceptPaymentRequest(COUNTERPARTY, PAYMENT_REQUEST_ID, 1uL)
}
}

Expand Down Expand Up @@ -2631,7 +2663,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) {
)
verify(paykitSdkService, never()).cancelPaymentRequest(any(), any(), anyOrNull())
verify(paykitSdkService, never()).rejectPaymentRequest(any(), any(), anyOrNull())
verify(paykitSdkService, never()).acceptPaymentRequest(any(), any())
verify(paykitSdkService, never()).acceptPaymentRequest(any(), any(), any())
val guarded = listOf(
proof.copy(hardwareRefusedForNavigation = false),
proof.copy(hardwareDispatchDenied = true),
Expand Down
Loading
Loading