diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 22df3a0733..152b68496e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -80,6 +80,7 @@ val e2eLocalHostEnv = providers.environmentVariable("E2E_LOCAL_HOST").orElse("10 val e2eHomegateUrlEnv = providers.environmentVariable("E2E_HOMEGATE_URL") .orElse(e2eLocalHostEnv.map { "http://$it:6288" }) val e2eHomeserverPubkyEnv = providers.environmentVariable("E2E_HOMESERVER_PUBKY").orElse("") +val e2eHomegateOverrideEnv = providers.environmentVariable("E2E_HOMEGATE_URL").orElse("") val geoEnv = envFlag("GEO", default = true) val paykitUiDisabledEnv = envFlag("PAYKIT_UI_DISABLED", default = false) val trezorBridgeEnv = localProp("TREZOR_BRIDGE").map { it.toBoolean().toString() }.orElse("false") @@ -361,6 +362,7 @@ androidComponents { buildConfigFields.put("E2E_LOCAL_HOST", e2eLocalHostEnv.stringField()) buildConfigFields.put("E2E_HOMEGATE_URL", e2eHomegateUrlEnv.stringField()) buildConfigFields.put("E2E_HOMESERVER_PUBKY", e2eHomeserverPubkyEnv.stringField()) + buildConfigFields.put("E2E_HOMEGATE_OVERRIDE", e2eHomegateOverrideEnv.stringField()) buildConfigFields.put("TREZOR_BRIDGE", trezorBridgeEnv.booleanField()) buildConfigFields.put("TREZOR_BRIDGE_URL", trezorBridgeUrlEnv.stringField()) buildConfigFields.put("GEO", geoEnv.booleanField()) diff --git a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt index b740c7c51c..8ac2cd39a2 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -247,6 +247,7 @@ class Keychain @Inject constructor( PAYKIT_ACCEPTED_PAYMENT_REQUESTS, PAYKIT_USDT_PAYMENTS, PAYKIT_PRESENTED_PAYMENT_REQUESTS, + PAYKIT_ALLOWANCE_STATE, PUBKY_SECRET_KEY, SHARED_PUBKY_SOURCE, } diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index f22b86336b..09797720d0 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -187,6 +187,10 @@ internal object Env { val homegateUrl: String get() { + // An explicit E2E_HOMEGATE_URL wins on every backend, as on iOS: demos run their own homegate. + if (isE2eTest && BuildConfig.E2E_HOMEGATE_OVERRIDE.isNotBlank()) { + return BuildConfig.E2E_HOMEGATE_OVERRIDE + } if (isLocalE2eBackend) { return e2eHomegateUrl } diff --git a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt index 7cb9da325c..24fc03dceb 100644 --- a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt +++ b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt @@ -63,6 +63,7 @@ data class PaykitPaymentStateBackup( val hardwareDispatchAttempted: Boolean? = null, val privatePaymentListVersion: ULong? = null, val previousPrivatePaymentListVersion: ULong? = null, + val allowanceId: String? = null, ) { init { require( @@ -93,7 +94,8 @@ data class PaykitPaymentStateBackup( hardwareTotalSpent = proof.hardwareTotalSpent, hardwareDispatchAttempted = proof.hardwareDispatchAttempted, privatePaymentListVersion = proof.privatePaymentListVersion, - previousPrivatePaymentListVersion = proof.previousPrivatePaymentListVersion + previousPrivatePaymentListVersion = proof.previousPrivatePaymentListVersion, + allowanceId = proof.allowanceId, ) fun restored() = PendingPaykitPaymentProof( @@ -118,7 +120,8 @@ data class PaykitPaymentStateBackup( hardwareTotalSpent = hardwareTotalSpent, hardwareDispatchAttempted = hardwareDispatchAttempted, privatePaymentListVersion = privatePaymentListVersion, - previousPrivatePaymentListVersion = previousPrivatePaymentListVersion + previousPrivatePaymentListVersion = previousPrivatePaymentListVersion, + allowanceId = allowanceId, ) } } diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 03b15f398d..d1752edce9 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1408,10 +1408,18 @@ class LightningRepo @Inject constructor( sats: ULong?, paymentDeadlineAt: Instant?, onBeforeSend: suspend () -> Boolean, + ): Result = payInvoice(bolt11, sats, paymentDeadlineAt, maxTotalRoutingFeeMsat = null, onBeforeSend) + + suspend fun payInvoice( + bolt11: String, + sats: ULong?, + paymentDeadlineAt: Instant?, + maxTotalRoutingFeeMsat: ULong?, + onBeforeSend: suspend () -> Boolean, ): Result = executeWhenNodeRunning("payInvoice") { waitForUsableChannels() if (!onBeforeSend()) return@executeWhenNodeRunning Result.failure(PaymentAbortedBeforeSend()) - runCatching { lightningService.send(bolt11, sats, paymentDeadlineAt) }.also { + runCatching { lightningService.send(bolt11, sats, paymentDeadlineAt, maxTotalRoutingFeeMsat) }.also { syncState() } } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt new file mode 100644 index 0000000000..0a5e2a0dcb --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt @@ -0,0 +1,299 @@ +package to.bitkit.repositories + +import androidx.compose.runtime.Immutable +import com.synonym.paykit.AllowanceAccountingHistory +import com.synonym.paykit.AllowanceAmountRange +import com.synonym.paykit.AllowanceLifecycleState +import com.synonym.paykit.AllowanceLocalRole +import com.synonym.paykit.AllowancePeriod +import com.synonym.paykit.AllowancePeriodLimit +import com.synonym.paykit.AllowanceRecord +import com.synonym.paykit.AllowanceTerms +import com.synonym.paykit.PaymentExecutionMode +import com.synonym.paykit.PaymentExecutionStatus +import kotlinx.serialization.Serializable +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.models.safe +import java.math.BigDecimal +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import kotlin.time.Instant +import kotlin.time.toJavaInstant +import kotlin.time.toKotlinInstant + +/** + * An Allowance between this wallet's identity and one contact identity, built from the SDK record. + * Eligibility always runs on real time. + */ +@Immutable +data class PaykitAllowance( + val id: Id, + val role: Role, + val lifecycleState: AllowanceLifecycleState, + val isProposedByMe: Boolean, + val perPaymentMaxSats: ULong?, + val monthlyLimitSats: ULong?, + val monthlyAnchor: Instant?, + val activeFrom: Instant? = null, + val expiresAt: Instant? = null, + val allowedPaymentEndpointIdentifiers: List? = null, + val lastEventAt: Instant? = null, + /** The received proposal's private stream item; accepting it is bound to this item. */ + val proposalStreamItemId: ULong? = null, +) { + @Serializable + data class Id( + val counterparty: String, + val allowanceId: String, + ) + + @Serializable + enum class Role { ALLOWER, ALLOWEE } + + enum class Status { + /** Sent by this wallet; the other side has not answered yet. */ + AWAITING_ANSWER, + + /** Received; this wallet must accept or decline. */ + AWAITING_MY_ANSWER, + ACTIVE, + NOT_YET_ACTIVE, + EXPIRED, + DECLINED, + ENDED, + CONFLICTED, + } + + val counterparty: String get() = id.counterparty + val allowanceId: String get() = id.allowanceId + + /** The payer side: this wallet pays the counterparty's requests automatically. */ + val isAllower: Boolean get() = role == Role.ALLOWER + + /** Allowance ids are unique per contact only, so this identifies one Allowance across all contacts. */ + val key: String get() = key(counterparty, allowanceId) + + val canEnd: Boolean + get() = when (lifecycleState) { + AllowanceLifecycleState.ACCEPTED -> true + AllowanceLifecycleState.PROPOSED -> isProposedByMe + else -> false + } + + val isAnswerable: Boolean get() = lifecycleState == AllowanceLifecycleState.PROPOSED && !isProposedByMe + + fun status(now: Instant): Status = when (lifecycleState) { + AllowanceLifecycleState.PROPOSED -> if (isProposedByMe) Status.AWAITING_ANSWER else Status.AWAITING_MY_ANSWER + AllowanceLifecycleState.ACCEPTED -> when { + expiresAt != null && now >= expiresAt -> Status.EXPIRED + activeFrom != null && now < activeFrom -> Status.NOT_YET_ACTIVE + else -> Status.ACTIVE + } + AllowanceLifecycleState.REJECTED -> Status.DECLINED + AllowanceLifecycleState.ENDED -> Status.ENDED + AllowanceLifecycleState.CONFLICTED, AllowanceLifecycleState.UNKNOWN -> Status.CONFLICTED + } + + companion object { + fun key(counterparty: String, allowanceId: String?): String = + "${PubkyPublicKeyFormat.normalized(counterparty) ?: counterparty}/$allowanceId" + + fun from(record: AllowanceRecord): PaykitAllowance? { + val role = when (record.localRole) { + AllowanceLocalRole.ALLOWER -> Role.ALLOWER + AllowanceLocalRole.ALLOWEE -> Role.ALLOWEE + else -> return null + } + val terms = record.terms ?: return null + if (terms.asset() != PaykitIssuerInterop.BITCOIN_ASSET) return null + val monthly = terms.periodLimits().firstOrNull { isMonthly(it.period()) } + val perPaymentMaxSats = terms.perPaymentAmount()?.let { satsFromBitcoinAmount(it.maximum()) } + val monthlyLimitSats = monthly?.amountLimit()?.let(::satsFromBitcoinAmount) + val monthlyAnchor = monthly?.period()?.anchor()?.let(PaykitAllowanceTime::parse) + // This wallet pays only under limits it can show: terms without a readable per-payment and monthly cap + // are dropped. + val isBounded = perPaymentMaxSats != null && monthlyLimitSats != null && monthlyAnchor != null + if (role == Role.ALLOWER && !isBounded) return null + return PaykitAllowance( + id = Id(record.counterparty, record.allowanceId), + role = role, + lifecycleState = record.state, + isProposedByMe = record.proposalOutboundMessageId != null, + perPaymentMaxSats = perPaymentMaxSats, + monthlyLimitSats = monthlyLimitSats, + monthlyAnchor = monthlyAnchor, + activeFrom = terms.activeFrom()?.let(PaykitAllowanceTime::parse), + expiresAt = terms.expiresAt()?.let(PaykitAllowanceTime::parse), + allowedPaymentEndpointIdentifiers = terms.allowedPaymentEndpointIdentifiers(), + lastEventAt = record.lastEventAt?.let(PaykitAllowanceTime::parse), + proposalStreamItemId = record.proposalStreamItemId, + ) + } + + fun isMonthly(period: AllowancePeriod): Boolean = + period.kind() == "anchored" && period.every() == 1uL && period.unit() == "month" + + /** BTC decimal string to sats; unlike [toPaykitSats] a zero amount is valid here. */ + fun satsFromBitcoinAmount(amount: String): ULong? { + if (amount.split('.').all { part -> part.all { it == '0' } }) return 0uL + return amount.toPaykitSats() + } + } +} + +/** One grant as the user sees it: the Allowance with a contact identity and the limits picked for it. */ +@Immutable +data class PaykitAllowanceEntry( + val id: String, + val allowances: List, + val limits: PaykitAllowanceLimits?, +) { + /** An accepted Allowance before any other. */ + val primary: PaykitAllowance + get() = allowances.firstOrNull { it.lifecycleState == AllowanceLifecycleState.ACCEPTED } ?: allowances.first() + val counterparty: String get() = primary.counterparty + val role: PaykitAllowance.Role get() = primary.role + val perPaymentMaxSats: ULong? get() = primary.perPaymentMaxSats + val monthlyLimitSats: ULong? get() = primary.monthlyLimitSats + val canEnd: Boolean get() = allowances.any { it.canEnd } + val isAnswerable: Boolean get() = allowances.any { it.isAnswerable } + + fun status(now: Instant): PaykitAllowance.Status = primary.status(now) +} + +/** Limits picked in USD on the Set Allowance sheet, converted once to whole-sat BTC terms. */ +@Serializable +@Immutable +data class PaykitAllowanceLimits( + val perPaymentUsd: Int, + val monthlyUsd: Int, + val perPaymentSats: ULong, + val monthlySats: ULong, +) { + /** + * Terms Bitkit proposes: per-payment range 0...max, an anchored UTC calendar month, and the endpoints Bitkit pays. + */ + fun terms(monthAnchor: Instant, allowedPaymentEndpointIdentifiers: List): AllowanceTerms { + val perPayment = AllowanceAmountRange(minimum = "0", maximum = perPaymentSats.toBitcoinDecimal()) + val month = AllowancePeriod( + kind = "anchored", + every = 1uL, + unit = "month", + anchor = PaykitAllowanceTime.format(monthAnchor), + ) + val monthly = AllowancePeriodLimit( + amountLimit = monthlySats.toBitcoinDecimal(), + paymentCountLimit = null, + period = month, + ) + return AllowanceTerms( + asset = PaykitIssuerInterop.BITCOIN_ASSET, + perPaymentAmount = perPayment, + periodLimits = listOf(monthly), + lifetimeAmountLimit = null, + activeFrom = null, + expiresAt = null, + allowedPaymentEndpointIdentifiers = allowedPaymentEndpointIdentifiers, + ) + } + + companion object { + /** Slider stops on the Set Allowance sheet, in whole US dollars. */ + val PER_PAYMENT_STOPS_USD = listOf(1, 5, 10, 20, 50) + + /** Slider stops on the Set Allowance sheet, in whole US dollars. */ + val MONTHLY_STOPS_USD = listOf(10, 50, 100, 200, 500) + } +} + +internal fun ULong.toBitcoinDecimal(): String = + BigDecimal(toString()).movePointLeft(8).stripTrailingZeros().toPlainString() + +object PaykitAllowanceTime { + private val utc = ZoneOffset.UTC + + fun format(instant: Instant): String = + DateTimeFormatter.ISO_INSTANT.format(instant.toJavaInstant().truncatedTo(ChronoUnit.MILLIS)) + + fun parse(value: String): Instant? = runCatching { Instant.parse(value) }.getOrNull() + + /** First instant of the UTC calendar month that contains [instant]. */ + fun monthStart(containing: Instant): Instant = ZonedDateTime.ofInstant(containing.toJavaInstant(), utc) + .withDayOfMonth(1) + .truncatedTo(ChronoUnit.DAYS) + .toInstant() + .toKotlinInstant() + + /** + * The anchored monthly window `[start, end)` that contains [instant]. Anchors on a day that a short month lacks + * clamp to that month's last day, as the spec's anchored-period arithmetic does. + */ + fun monthlyWindow(anchor: Instant, containing: Instant): Pair { + val anchorTime = ZonedDateTime.ofInstant(anchor.toJavaInstant(), utc) + val dateTime = ZonedDateTime.ofInstant(containing.toJavaInstant(), utc) + // Every boundary counts from the original anchor, so a clamped February never shortens later months. + val boundary = { index: Long -> anchorTime.plusMonths(index).toInstant().toKotlinInstant() } + var index = (dateTime.year - anchorTime.year) * 12L + dateTime.monthValue - anchorTime.monthValue + while (boundary(index) > containing) index-- + while (boundary(index + 1) <= containing) index++ + return boundary(index) to boundary(index + 1) + } +} + +/** + * Wallet-side capacity preflight. The SDK checks capacity only after automatic Acceptance, so Bitkit sums this + * Allowance's live automatic attempts in the current month first and keeps an over-cap request on the manual flow. + */ +object PaykitAllowanceCapacity { + /** Allowance ids are unique per contact only, so an attempt keeps the contact it paid. */ + data class Attempt( + val counterparty: String, + val allowanceId: String, + val amountSats: ULong, + val admittedAt: Instant, + val isLive: Boolean, + ) + + fun usedSats( + counterparty: String, + allowanceId: String, + attempts: List, + anchor: Instant, + now: Instant, + ): ULong { + val (start, end) = PaykitAllowanceTime.monthlyWindow(anchor, now) + return attempts + .filter { + it.allowanceId == allowanceId && PubkyPublicKeyFormat.matches(it.counterparty, counterparty) && + it.isLive && it.admittedAt >= start && it.admittedAt < end + } + .fold(0uL) { total, attempt -> total.safe() + attempt.amountSats.safe() } + } + + fun fits(amountSats: ULong, allowance: PaykitAllowance, attempts: List, now: Instant): Boolean { + val perPaymentMax = allowance.perPaymentMaxSats ?: return false + if (amountSats > perPaymentMax) return false + val monthlyLimit = allowance.monthlyLimitSats ?: return false + val anchor = allowance.monthlyAnchor ?: return false + val used = usedSats(allowance.counterparty, allowance.allowanceId, attempts, anchor, now) + return used.safe() + amountSats.safe() <= monthlyLimit + } + + fun attempts(history: AllowanceAccountingHistory): List = history.occurrences + .flatMap { occurrence -> occurrence.attempts.map { occurrence.key.request.counterparty to it } } + .mapNotNull { (counterparty, attempt) -> + if (attempt.mode != PaymentExecutionMode.AUTOMATIC) return@mapNotNull null + val allowanceId = attempt.allowanceId ?: return@mapNotNull null + val admittedAt = PaykitAllowanceTime.parse(attempt.admittedAt) ?: return@mapNotNull null + val amountSats = PaykitAllowance.satsFromBitcoinAmount(attempt.amount.value()) ?: return@mapNotNull null + Attempt( + counterparty = counterparty, + allowanceId = allowanceId, + amountSats = amountSats, + admittedAt = admittedAt, + isLive = attempt.status != PaymentExecutionStatus.FAILED, + ) + } +} diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt new file mode 100644 index 0000000000..bbaf74a6df --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt @@ -0,0 +1,933 @@ +package to.bitkit.repositories + +import com.synonym.paykit.AccountingAmount +import com.synonym.paykit.AllowanceAccountingBlock +import com.synonym.paykit.AllowanceAccountingHistory +import com.synonym.paykit.AllowanceAccountingReconciliation +import com.synonym.paykit.AllowanceAccountingState +import com.synonym.paykit.AllowanceLifecycleState +import com.synonym.paykit.AllowanceSelectionInput +import com.synonym.paykit.PaymentAttemptDecision +import com.synonym.paykit.PaymentAttemptRecord +import com.synonym.paykit.PaymentExecutionChecks +import com.synonym.paykit.PaymentExecutionStatus +import com.synonym.paykit.PaymentOccurrence +import com.synonym.paykit.PaymentOutcome +import com.synonym.paykit.PaymentOutcomeReport +import com.synonym.paykit.PaymentRequestLifecycleState +import com.synonym.paykit.PaymentRequestScope +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.lightningdevkit.ldknode.PaymentDirection +import org.lightningdevkit.ldknode.PaymentStatus +import to.bitkit.di.IoDispatcher +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.PaykitAsset +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.models.safe +import to.bitkit.repositories.PaykitAllowanceLocalState.JournalEntry +import to.bitkit.repositories.PaykitAllowanceLocalState.Stage +import to.bitkit.services.PaykitSdkService +import to.bitkit.utils.Logger +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.Instant + +/** The side effects of paying one request, kept apart so the admission logic is testable without a node. */ +@Singleton +@Suppress("TooManyFunctions") +class PaykitAllowancePayer @Inject constructor( + private val privatePaykitRepo: PrivatePaykitRepo, + private val paymentProofRepo: PaykitPaymentProofRepo, + private val lightningRepo: LightningRepo, + private val paymentRequestRepo: PaykitPaymentRequestRepo, +) { + suspend fun resolve( + request: PaykitPaymentRequest, + eligibleIdentifiers: List, + ): Result = privatePaykitRepo.resolveAllowancePayment(request, eligibleIdentifiers) + + suspend fun consumePaymentList(publicKey: String, context: PrivatePaykitPaymentContext): Result = + privatePaykitRepo.consumePrivatePaymentList(publicKey, context) + + /** Gives back a consumed payment list, so the request's manual fallback can still use it. */ + suspend fun releasePaymentList(publicKey: String, context: PrivatePaykitPaymentContext): Result = + privatePaykitRepo.releasePrivatePaymentList(publicKey, context) + + /** Accepts through [accept] with this install as the request's owner, so a failed payment stays payable here. */ + suspend fun acceptOnThisInstall(request: PaykitPaymentRequest, accept: suspend () -> T): Result = + paymentRequestRepo.acceptOnThisInstall(request, accept) + + suspend fun prepareProof( + request: PaykitPaymentRequest, + paymentEndpointIdentifier: String, + paymentAppId: String, + allowanceId: String?, + ): Result { + val kind = PaykitPaymentProofKind.fromPaymentEndpointIdentifier(paymentEndpointIdentifier) + ?: return Result.failure(PaykitPaymentRequestError.RequestUnavailable) + return paymentProofRepo.prepare( + request, + paymentEndpointIdentifier, + paymentAppId, + kind, + allowanceId = allowanceId + ) + } + + suspend fun associateLightningPayment( + request: PaykitPaymentRequest, + paymentHash: String, + paymentEndpointIdentifier: String, + paymentAppId: String, + ): Result = + paymentProofRepo.associateLightningPayment(request, paymentHash, paymentEndpointIdentifier, paymentAppId) + + suspend fun payLightning( + bolt11: String, + sats: ULong?, + paymentDeadlineAt: Instant?, + maxTotalRoutingFeeMsat: ULong, + ): Result = lightningRepo.payInvoice(bolt11, sats, paymentDeadlineAt, maxTotalRoutingFeeMsat) { true } + + /** Clears the proof when [error] proves the payment never left; returns whether it did. */ + suspend fun failLightningPayment(paymentHash: String, error: Throwable): Boolean = + paymentProofRepo.failLightningPayment(paymentHash, error) + + suspend fun discardLightningProof(paymentHash: String) = paymentProofRepo.failLightningPayment(paymentHash) + + suspend fun cancelProofPreparation(request: PaykitPaymentRequest) = paymentProofRepo.cancelPreparation(request) + + /** + * SUCCEEDED once the wallet holds positive evidence that [txid] paid [requestId], else null: a refused or unknown + * broadcast stays unresolved until the wallet's recovery of the send attempt settles it. + */ + suspend fun onchainPaymentOutcome(requestId: PaykitPaymentRequestId, txid: String): PaymentOutcome? { + val attempt = runSuspendCatching { lightningRepo.currentOnchainSendAttempt() }.getOrNull() + val isAttemptAccepted = attempt != null && attempt.requestId == requestId && attempt.hasPositiveEvidence && + (attempt.txid.equals(txid, ignoreCase = true) || txid.lowercase() in attempt.candidateTxids) + if (isAttemptAccepted) return PaymentOutcome.SUCCEEDED + val hasProof = runSuspendCatching { + paymentProofRepo.hasVerifiedOnchainProof( + requestId, + txid + ) + }.getOrDefault(false) + return PaymentOutcome.SUCCEEDED.takeIf { hasProof } + } + + /** The node's status for an outbound payment, or null when the node does not know it (or is not running). */ + suspend fun lightningPaymentStatus(paymentHash: String): PaymentStatus? = + runSuspendCatching { lightningRepo.listPaymentsOrNull() } + .getOrNull() + ?.firstOrNull { it.direction == PaymentDirection.OUTBOUND && it.id.equals(paymentHash, ignoreCase = true) } + ?.status +} + +/** + * Runs Allowance admission for incoming requests through the SDK: evaluate, capacity preflight, automatic + * Acceptance, reserve, begin, pay, record the outcome. Every attempt is journaled before its handoff, so a restart + * resolves it from the node instead of paying again. + */ +@Singleton +@Suppress("TooManyFunctions") +class PaykitAllowanceExecutor @Inject constructor( + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val paykitSdkService: PaykitSdkService, + private val store: PaykitAllowanceStore, + private val payer: PaykitAllowancePayer, + private val clock: Clock, +) { + companion object { + private const val TAG = "PaykitAllowanceExecutor" + private const val MAX_JOURNAL_ENTRIES = 200 + + /** 1% of each paid sat (1 000 msat) may go to routing fees on an automatic payment. */ + private const val MAX_ROUTING_FEE_MSAT_PER_SAT = 10uL + private val SETTLEABLE_STAGES = setOf(Stage.SUBMITTED, Stage.SENDING, Stage.SENT, Stage.UNKNOWN) + } + + private var accountingAmount: (String, String) -> AccountingAmount = ::AccountingAmount + private val stateMutex = Mutex() + private val settleMutex = Mutex() + private val inFlightRequestIds = ConcurrentHashMap.newKeySet() + private val liveAttemptIds = ConcurrentHashMap.newKeySet() + private val _events = MutableSharedFlow(extraBufferCapacity = 16) + val events: SharedFlow = _events.asSharedFlow() + + @Volatile + var activeIdentity: String? = null + private set + + internal constructor( + ioDispatcher: CoroutineDispatcher, + paykitSdkService: PaykitSdkService, + store: PaykitAllowanceStore, + payer: PaykitAllowancePayer, + clock: Clock, + accountingAmount: (String, String) -> AccountingAmount, + ) : this(ioDispatcher, paykitSdkService, store, payer, clock) { + this.accountingAmount = accountingAmount + } + + fun activate(identity: String?) = run { activeIdentity = identity } + + // region Local state + + fun localState(identity: String): PaykitAllowanceLocalState = store.load(identity) + + suspend fun updateLocalState( + identity: String, + change: (PaykitAllowanceLocalState) -> PaykitAllowanceLocalState, + ): PaykitAllowanceLocalState = stateMutex.withLock { + val updated = change(localState(identity)) + runSuspendCatching { store.save(identity, updated) } + .onFailure { Logger.warn("Failed to save Paykit allowance state", it, context = TAG) } + updated + } + + private suspend fun updateLocalStateDurably( + identity: String, + change: (PaykitAllowanceLocalState) -> PaykitAllowanceLocalState, + ): Unit = stateMutex.withLock { store.save(identity, change(localState(identity))) } + + fun isHandling(requestId: PaykitPaymentRequestId): Boolean = requestId in inFlightRequestIds + + /** + * Trusted time for eligibility: the real clock, never earlier than the last value given to the SDK, because the + * SDK refuses a watermark that moves backwards. + */ + suspend fun trustedTime(identity: String): String { + var millis = clock.now().toEpochMilliseconds() + updateLocalState(identity) { state -> + state.lastTrustedTimeMillis?.let { millis = maxOf(millis, it) } + state.copy(lastTrustedTimeMillis = millis) + } + return PaykitAllowanceTime.format(Instant.fromEpochMilliseconds(millis)) + } + + /** + * Whether [paymentHash] is an automatic Allowance payment, so no foreground send flow should claim it. The newest + * entry for the hash decides: a manual retry of a failed automatic invoice is journaled later and is the user's. + */ + fun isAutomaticPayment(paymentHash: String): Boolean { + val identity = activeIdentity ?: return false + return localState(identity).journal + .lastOrNull { it.paymentHash.equals(paymentHash, ignoreCase = true) } + ?.isAutomatic == true + } + + fun succeededAutomaticPayments(identity: String): List = + localState(identity).journal.filter { it.isAutomatic && it.stage == Stage.SUCCEEDED } + + // endregion + + // region Ledger + + /** + * Brings the SDK ledger to a reconciled state. A wallet with no ledger attests an empty history: it has never paid + * through an Allowance. After a restore, outcomes come from the journal and the node. + */ + suspend fun ensureReconciled(identity: String): AllowanceAccountingState = withContext(ioDispatcher) { + val current = paykitSdkService.allowanceAccountingState() + if (current != null && !current.requiresReconciliation) return@withContext current + + val history = current?.history ?: AllowanceAccountingHistory(emptyList(), emptyList(), emptyList()) + val outcomes = history.occurrences.flatMap { it.attempts }.mapNotNull { attempt -> + verifiedOutcome(attempt, identity)?.let { PaymentOutcomeReport(attempt.attemptId, it) } + } + val reconciled = paykitSdkService.reconcileAllowanceAccounting( + AllowanceAccountingReconciliation( + expectedRevision = current?.revision, + history = history, + outcomes = outcomes, + trustedTime = trustedTime(identity), + ), + ) + Logger.info("Reconciled Paykit allowance accounting with '${outcomes.size}' verified outcomes", context = TAG) + reconciled + } + + /** + * Resolves attempts a crash or kill left open. Prepared attempts never got a handoff and are released; submitted + * ones are settled from the journal and the node. Nothing is paid again, and attempts this process is still + * executing are left alone. + */ + suspend fun recover(identity: String) { + withContext(ioDispatcher) { recoverOpenAttempts(identity) } + } + + private suspend fun recoverOpenAttempts(identity: String) { + runSuspendCatching { + // No ledger means nothing was ever admitted, so there is nothing to recover. + paykitSdkService.allowanceAccountingState() ?: return@runSuspendCatching + val state = ensureReconciled(identity) + for (attempt in state.history.occurrences.flatMap { it.attempts }) { + if (attempt.attemptId in liveAttemptIds) continue + when (attempt.status) { + PaymentExecutionStatus.PREPARED -> { + if (attempt.epoch == state.epoch) record(attempt.attemptId, PaymentOutcome.FAILED, identity) + } + PaymentExecutionStatus.SUBMITTED, PaymentExecutionStatus.UNKNOWN -> { + val outcome = verifiedOutcome(attempt, identity) + ?: PaymentOutcome.UNKNOWN.takeIf { attempt.status == PaymentExecutionStatus.SUBMITTED } + val entry = localState(identity).journal.firstOrNull { it.attemptId == attempt.attemptId } + outcome?.let { record(attempt.attemptId, it, identity) } + // One that paid nothing gives the payee's list back, as a live failure does. + if (outcome == PaymentOutcome.FAILED && entry?.isAutomatic == true) { + releasePaymentList(entry, identity) + } + } + // Reconciliation can settle an attempt in the ledger before its journal entry follows. + PaymentExecutionStatus.SUCCEEDED -> syncSettledJournal(attempt.attemptId, Stage.SUCCEEDED, identity) + PaymentExecutionStatus.FAILED -> syncSettledJournal(attempt.attemptId, Stage.FAILED, identity) + } + } + }.onFailure { Logger.warn("Failed to recover Paykit allowance attempts", it, context = TAG) } + } + + private suspend fun syncSettledJournal(attemptId: String, stage: Stage, identity: String) { + val current = localState(identity).journal.firstOrNull { it.attemptId == attemptId } ?: return + if (current.stage == Stage.SUCCEEDED || current.stage == Stage.FAILED) return + val before = applyJournalStage(attemptId, stage, identity) ?: return + if (stage == Stage.FAILED && before.isAutomatic) releasePaymentList(before, identity) + } + + /** + * True when [request] fits no active Allowance of its contact, checked locally while the node cannot pay yet; + * such a request asks at once instead of waiting for channels, and raises Limit Reached. + */ + suspend fun classifyAboveLimit( + request: PaykitPaymentRequest, + allowances: List, + identity: String, + ): Boolean = withContext(ioDispatcher) { + val amountSats = request.allowanceSats() ?: return@withContext false + val now = clock.now() + val matching = allowances.filter { + it.isAllower && it.status(now) == PaykitAllowance.Status.ACTIVE && + PubkyPublicKeyFormat.matches(it.counterparty, request.counterparty) + } + if (matching.isEmpty()) return@withContext false + val attempts = automaticAttempts() + if (matching.any { PaykitAllowanceCapacity.fits(amountSats, it, attempts, now) }) return@withContext false + notifyLimitReached(request, identity) + true + } + + /** Settles journaled Lightning attempts whose outcome the node already knows, for events missed while inactive. */ + suspend fun settleOpenLightningAttempts(): Unit = withContext(ioDispatcher) { + val identity = activeIdentity ?: return@withContext + val paymentHashes = localState(identity).journal + .filter { it.stage in SETTLEABLE_STAGES } + .mapNotNull { it.paymentHash } + .distinct() + for (paymentHash in paymentHashes) { + when (payer.lightningPaymentStatus(paymentHash)) { + PaymentStatus.SUCCEEDED -> lightningPaymentSettled(paymentHash, succeeded = true) + PaymentStatus.FAILED -> lightningPaymentSettled(paymentHash, succeeded = false) + PaymentStatus.PENDING, null -> Unit + } + } + } + + private suspend fun verifiedOutcome(attempt: PaymentAttemptRecord, identity: String): PaymentOutcome? = + when (attempt.status) { + PaymentExecutionStatus.PREPARED -> PaymentOutcome.FAILED + PaymentExecutionStatus.SUCCEEDED, PaymentExecutionStatus.FAILED -> null + PaymentExecutionStatus.SUBMITTED, PaymentExecutionStatus.UNKNOWN -> localState(identity).journal + .firstOrNull { it.attemptId == attempt.attemptId } + ?.let { journalOutcome(it) } + } + + private suspend fun journalOutcome(entry: JournalEntry): PaymentOutcome? = when (entry.stage) { + // The journal is written before the node call, so no payment left this wallet. + Stage.PREPARED, Stage.SUBMITTED, Stage.FAILED -> PaymentOutcome.FAILED + Stage.SUCCEEDED -> PaymentOutcome.SUCCEEDED + Stage.SENDING, Stage.SENT, Stage.UNKNOWN -> { + val paymentHash = entry.paymentHash + if (paymentHash == null) { + entry.transactionId?.let { payer.onchainPaymentOutcome(entry.requestId, it) } + } else { + when (payer.lightningPaymentStatus(paymentHash)) { + PaymentStatus.SUCCEEDED -> PaymentOutcome.SUCCEEDED + PaymentStatus.FAILED -> PaymentOutcome.FAILED + PaymentStatus.PENDING, null -> null + } + } + } + } + + private suspend fun record(attemptId: String, outcome: PaymentOutcome, identity: String) { + paykitSdkService.recordPaymentOutcome(PaymentOutcomeReport(attemptId, outcome)) + val stage = when (outcome) { + PaymentOutcome.SUCCEEDED -> Stage.SUCCEEDED + PaymentOutcome.FAILED -> Stage.FAILED + PaymentOutcome.UNKNOWN -> Stage.UNKNOWN + } + applyJournalStage(attemptId, stage, identity) + _events.tryEmit(PaykitAllowanceEvent.LedgerChanged) + } + + /** + * Moves an attempt's journal entry to [stage]. The one place that announces an automatic payment as paid: only the + * update that moves it to SUCCEEDED does, so settlement and recovery never announce it twice. + */ + private suspend fun applyJournalStage(attemptId: String, stage: Stage, identity: String): JournalEntry? { + var before: JournalEntry? = null + updateLocalState(identity) { state -> + before = state.journal.firstOrNull { it.attemptId == attemptId } + state.copy(journal = state.journal.map { if (it.attemptId == attemptId) it.copy(stage = stage) else it }) + } + val entry = before ?: return null + if (entry.isAutomatic && entry.stage != Stage.SUCCEEDED && stage == Stage.SUCCEEDED) { + val paymentId = entry.paymentHash?.lowercase() ?: entry.transactionId + if (paymentId != null) { + _events.tryEmit( + PaykitAllowanceEvent.PaidAutomatically(entry.requestId.counterparty, entry.amountSats, paymentId), + ) + } + } + return entry + } + + private suspend fun automaticAttempts(): List = + runSuspendCatching { paykitSdkService.allowanceAccountingState() } + .getOrNull() + ?.let { PaykitAllowanceCapacity.attempts(it.history) } + .orEmpty() + + // endregion + + // region Automatic payment + + suspend fun autoPay( + request: PaykitPaymentRequest, + allowances: List, + identity: String, + ): PaykitAllowanceAutoPayResult = withContext(ioDispatcher) { + val isCovered = request.direction == PaykitPaymentRequestDirection.Incoming && + request.billingPeriod == null && + request.lifecycleState == PaymentRequestLifecycleState.PROPOSED && + allowances.any { + it.isAllower && + it.lifecycleState == AllowanceLifecycleState.ACCEPTED && + PubkyPublicKeyFormat.matches(it.counterparty, request.counterparty) + } + if (!isCovered || !inFlightRequestIds.add(request.id)) { + return@withContext PaykitAllowanceAutoPayResult.NOT_COVERED + } + + try { + runSuspendCatching { + ensureReconciled(identity) + // Survive a cancelled caller: stopping after the acceptance would leave the request unpaid. + withContext(NonCancellable) { admitAndPay(request, allowances, identity) } + }.getOrElse { + Logger.warn("Kept an incoming request on the manual flow after an allowance error", it, context = TAG) + PaykitAllowanceAutoPayResult.MANUAL + } + } finally { + inFlightRequestIds.remove(request.id) + } + } + + @Suppress("ReturnCount", "LongMethod") + private suspend fun admitAndPay( + request: PaykitPaymentRequest, + allowances: List, + identity: String, + ): PaykitAllowanceAutoPayResult { + val amountSats = request.allowanceSats() + if (amountSats == null) { + Logger.info("Kept an incoming request manual: allowances pay only bitcoin amounts", context = TAG) + return PaykitAllowanceAutoPayResult.MANUAL + } + val scope = request.scope() + val selectionTime = trustedTime(identity) + val candidates = paykitSdkService.evaluateAllowanceCandidates(scope, selectionTime) + val candidate = candidates.firstOrNull { it.blocked == null } + val allowance = candidate?.let { eligible -> + allowances.firstOrNull { + it.allowanceId == eligible.allowanceId && + PubkyPublicKeyFormat.matches(it.counterparty, request.counterparty) + } + } + if (candidate == null || allowance == null) { + val reasons = candidates.mapNotNull { it.blocked } + Logger.info("Kept an incoming request manual: no eligible allowance, blocked by '$reasons'", context = TAG) + // The SDK blocks a request above the per-payment limit before the capacity check below can notify. + val now = clock.now() + val isAbovePerPaymentMax = allowances.any { granted -> + granted.isAllower && granted.status(now) == PaykitAllowance.Status.ACTIVE && + PubkyPublicKeyFormat.matches(granted.counterparty, request.counterparty) && + granted.perPaymentMaxSats?.let { amountSats > it } == true + } + if (isAbovePerPaymentMax) notifyLimitReached(request, identity) + return PaykitAllowanceAutoPayResult.MANUAL + } + + if (!PaykitAllowanceCapacity.fits(amountSats, allowance, automaticAttempts(), clock.now())) { + Logger.info("Kept an incoming request manual: the allowance limit is reached", context = TAG) + notifyLimitReached(request, identity) + return PaykitAllowanceAutoPayResult.MANUAL + } + + val payment = payer.resolve(request, candidate.eligiblePaymentEndpointIdentifiers).getOrElse { + if (it !is PaykitAllowanceError.PaymentListPending) throw it + Logger.info("Deferred an incoming request until the payee publishes a new payment list", context = TAG) + return PaykitAllowanceAutoPayResult.DEFERRED + } + if (payment == null) { + Logger.info("Kept an incoming request manual: no payable private endpoint", context = TAG) + return PaykitAllowanceAutoPayResult.MANUAL + } + if (payment.lightningPaymentHash == null) { + // An on-chain fee falls outside every allowance limit, so only Lightning pays automatically. + Logger.info("Kept an incoming request manual: the payee offers no Lightning invoice", context = TAG) + return PaykitAllowanceAutoPayResult.MANUAL + } + + val endpointIdentifier = payment.endpoint.methodId.rawValue + val association = payer.acceptOnThisInstall(request) { + // Every identity-wide execution step needs this app to own the request's execution claim first. + paykitSdkService.claimPaymentRequestForExecution(request.counterparty, request.paymentRequestId) + paykitSdkService.acceptPaymentRequestAutomatically( + scope = scope, + selection = AllowanceSelectionInput( + allowanceId = candidate.allowanceId, + expectedRevision = null, + trustedTime = selectionTime, + ), + checks = checks(request, endpointIdentifier, selectionTime), + ) + }.getOrThrow() + sendQueuedMessages(request) + + val occurrence = PaymentOccurrence(scope, billingPeriod = null) + val reservation = paykitSdkService.reserveAutomaticPayment( + occurrence = occurrence, + expectedAssociationRevision = association.revisions.lastOrNull()?.revision ?: 1uL, + checks = checks(request, endpointIdentifier, trustedTime(identity)), + ) + val prepared = (reservation as? PaymentAttemptDecision.Ready)?.attempt + if (prepared == null) { + val reason = (reservation as? PaymentAttemptDecision.Blocked)?.reason + Logger.info("Kept an incoming request manual: the reservation is blocked by '$reason'", context = TAG) + runSuspendCatching { paykitSdkService.markPaymentManualOnly(occurrence) } + .onFailure { Logger.warn("Failed to mark an allowance payment manual only", it, context = TAG) } + // Only a capacity block means the request is above the allowance; other blocks just keep it manual. + if (reason.isCapacityBlock()) notifyLimitReached(request, identity) + return PaykitAllowanceAutoPayResult.MANUAL + } + + liveAttemptIds.add(prepared.attemptId) + try { + return executeAutomaticAttempt(request, amountSats, payment, prepared, identity) + } finally { + liveAttemptIds.remove(prepared.attemptId) + } + } + + private suspend fun executeAutomaticAttempt( + request: PaykitPaymentRequest, + amountSats: ULong, + payment: PrivatePaykitAllowancePayment, + prepared: PaymentAttemptRecord, + identity: String, + ): PaykitAllowanceAutoPayResult { + val endpointIdentifier = payment.endpoint.methodId.rawValue + runSuspendCatching { + journal( + JournalEntry( + attemptId = prepared.attemptId, + isAutomatic = true, + requestId = request.id, + allowanceId = prepared.allowanceId, + amountSats = amountSats, + paymentEndpointIdentifier = endpointIdentifier, + paymentHash = payment.lightningPaymentHash, + onchainAddress = payment.endpoint.value.takeIf { payment.endpoint.methodId.isOnchain }, + stage = Stage.PREPARED, + createdAtMillis = clock.now().toEpochMilliseconds(), + paymentListVersion = payment.context.paymentListVersion, + previousPaymentListVersion = payment.context.previousPaymentListVersion, + ), + identity, + ) + }.onFailure { + record(prepared.attemptId, PaymentOutcome.FAILED, identity) + throw it + } + + // Begin fetches nothing, so pull the link first: an End or a cancellation must be seen before the handoff. + runSuspendCatching { + paykitSdkService.receivePrivateMessages(request.counterparty) + }.onFailure { Logger.warn("Failed to receive private messages before an allowance payment", it, context = TAG) } + val handoff = runSuspendCatching { + paykitSdkService.beginPaymentExecution( + attemptId = prepared.attemptId, + checks = checks(request, endpointIdentifier, trustedTime(identity)), + ) + }.getOrElse { + // Nothing was sent, so the reservation is released instead of blocking the manual payment. + record(prepared.attemptId, PaymentOutcome.FAILED, identity) + throw it + } + val submitted = (handoff as? PaymentAttemptDecision.Ready)?.attempt + if (submitted == null) { + val reason = (handoff as? PaymentAttemptDecision.Blocked)?.reason + Logger.info("Kept an incoming request manual: the allowance handoff is blocked by '$reason'", context = TAG) + record(prepared.attemptId, PaymentOutcome.FAILED, identity) + return PaykitAllowanceAutoPayResult.MANUAL + } + runSuspendCatching { setStage(Stage.SUBMITTED, submitted.attemptId, identity) }.onFailure { + record(submitted.attemptId, PaymentOutcome.FAILED, identity) + throw it + } + + payer.consumePaymentList(request.counterparty, payment.context).onFailure { + record(submitted.attemptId, PaymentOutcome.FAILED, identity) + throw it + } + payer.prepareProof(request, endpointIdentifier, payment.appId, submitted.allowanceId).onFailure { + payer.cancelProofPreparation(request) + failUnsent(request, payment, submitted.attemptId, identity) + throw it + } + + val paymentHash = checkNotNull(payment.lightningPaymentHash) { "Allowances pay only Lightning invoices" } + return payLightning(request, amountSats, payment, paymentHash, submitted.attemptId, identity) + } + + private suspend fun payLightning( + request: PaykitPaymentRequest, + amountSats: ULong, + payment: PrivatePaykitAllowancePayment, + paymentHash: String, + attemptId: String, + identity: String, + ): PaykitAllowanceAutoPayResult { + payer.associateLightningPayment( + request, + paymentHash, + payment.endpoint.methodId.rawValue, + payment.appId, + ).onFailure { + payer.cancelProofPreparation(request) + failUnsent(request, payment, attemptId, identity) + throw it + } + + runSuspendCatching { setStage(Stage.SENDING, attemptId, identity) }.onFailure { + payer.discardLightningProof(paymentHash) + failUnsent(request, payment, attemptId, identity) + throw it + } + val sats = amountSats.takeUnless { payment.lightningInvoiceHasAmount } + // Routing fees fall outside the allowance's accounting, so they are capped at 1% of the requested amount. + val maxRoutingFeeMsat = amountSats.safe() * MAX_ROUTING_FEE_MSAT_PER_SAT.safe() + payer.payLightning( + payment.endpoint.value, + sats, + request.submissionDeadline(null), + maxRoutingFeeMsat + ).onFailure { + // Only an error that proves the payment never left releases the reservation; anything else stays open, + // and the node's payment event records its outcome. + if (payer.failLightningPayment(paymentHash, it)) { + failUnsent(request, payment, attemptId, identity) + throw it + } + Logger.warn("Left an allowance payment with an uncertain send result to the node", it, context = TAG) + record(attemptId, PaymentOutcome.UNKNOWN, identity) + return PaykitAllowanceAutoPayResult.STARTED + } + // The node's payment event can settle the attempt before the send call returns; keep that outcome. + updateJournalEntry(attemptId, identity) { if (it.stage == Stage.SENDING) it.copy(stage = Stage.SENT) else it } + Logger.info("Handed an allowance payment to the node", context = TAG) + return PaykitAllowanceAutoPayResult.STARTED + } + + /** Called for every settled outbound Lightning payment; only journaled ones are Allowance work. */ + suspend fun lightningPaymentSettled(paymentHash: String, succeeded: Boolean): Unit = withContext(ioDispatcher) { + settleMutex.withLock { + val identity = activeIdentity ?: return@withLock + val entries = localState(identity).journal.filter { + it.paymentHash.equals(paymentHash, ignoreCase = true) && it.stage in SETTLEABLE_STAGES + } + for (entry in entries) { + runSuspendCatching { + val outcome = if (succeeded) PaymentOutcome.SUCCEEDED else PaymentOutcome.FAILED + record(entry.attemptId, outcome, identity) + // A payment the node gave up on paid nothing, so the payee's list stays usable for the manual one. + if (!succeeded && entry.isAutomatic) releasePaymentList(entry, identity) + }.onFailure { Logger.warn("Failed to record an allowance payment outcome", it, context = TAG) } + } + } + } + + // endregion + + // region Manual payments + + /** + * Reports a user-approved payment of an incoming request to the shared ledger before it leaves the wallet. + * Fails with [PaykitAllowanceError.PaymentAlreadyRecorded] when another live attempt exists for the request, so + * the same request is never paid twice. Any other problem leaves the payment unreported and returns null. + */ + suspend fun beginManualPayment( + request: PaykitPaymentRequest, + paymentEndpointIdentifier: String, + ): Result = withContext(ioDispatcher) { + val identity = activeIdentity + val amountSats = request.allowanceSats() + if ( + identity == null || + amountSats == null || + request.billingPeriod != null || + request.direction != PaykitPaymentRequestDirection.Incoming + ) { + return@withContext Result.success(null) + } + val result = runSuspendCatching { + reportManualPayment( + request, + amountSats, + paymentEndpointIdentifier, + identity + ) + } + val error = result.exceptionOrNull() ?: return@withContext result + if (error is PaykitAllowanceError.PaymentAlreadyRecorded) return@withContext result + Logger.warn("Failed to report a manual payment to the allowance ledger", error, context = TAG) + Result.success(null) + } + + private suspend fun reportManualPayment( + request: PaykitPaymentRequest, + amountSats: ULong, + paymentEndpointIdentifier: String, + identity: String, + ): String? { + ensureReconciled(identity) + val decision = paykitSdkService.reserveManualPayment( + occurrence = PaymentOccurrence(request.scope(), billingPeriod = null), + checks = checks(request, paymentEndpointIdentifier, trustedTime(identity)), + ) + val prepared = when (decision) { + is PaymentAttemptDecision.Ready -> decision.attempt + is PaymentAttemptDecision.Blocked if decision.reason == AllowanceAccountingBlock.PaymentAlreadyRecorded -> + throw PaykitAllowanceError.PaymentAlreadyRecorded + is PaymentAttemptDecision.Blocked -> { + Logger.info("Skipped reporting a manual payment: blocked by '${decision.reason}'", context = TAG) + return null + } + } + // Recovery leaves the attempt alone until finishManualPayment reports its outcome. + liveAttemptIds.add(prepared.attemptId) + val attemptId = runSuspendCatching { + beginManualAttempt(request, amountSats, paymentEndpointIdentifier, prepared, identity) + } + if (attemptId.getOrNull() == null) liveAttemptIds.remove(prepared.attemptId) + // Nothing was sent yet, so a failed journal write releases the attempt instead of blocking a retry. + attemptId.onFailure { + runSuspendCatching { record(prepared.attemptId, PaymentOutcome.FAILED, identity) } + .onFailure { Logger.warn("Failed to release a manual allowance attempt", it, context = TAG) } + } + return attemptId.getOrThrow() + } + + private suspend fun beginManualAttempt( + request: PaykitPaymentRequest, + amountSats: ULong, + paymentEndpointIdentifier: String, + prepared: PaymentAttemptRecord, + identity: String, + ): String? { + journal( + JournalEntry( + attemptId = prepared.attemptId, + isAutomatic = false, + requestId = request.id, + allowanceId = null, + amountSats = amountSats, + paymentEndpointIdentifier = paymentEndpointIdentifier, + stage = Stage.PREPARED, + createdAtMillis = clock.now().toEpochMilliseconds(), + ), + identity, + ) + val handoff = paykitSdkService.beginPaymentExecution( + attemptId = prepared.attemptId, + checks = checks(request, paymentEndpointIdentifier, trustedTime(identity)), + ) + if (handoff !is PaymentAttemptDecision.Ready) { + record(prepared.attemptId, PaymentOutcome.FAILED, identity) + return null + } + // An on-chain send marks nothing before it broadcasts, so recovery must not read its attempt as never sent. + val isOnchain = PaykitPaymentProofKind.fromPaymentEndpointIdentifier(paymentEndpointIdentifier) == + PaykitPaymentProofKind.Onchain + setStage(if (isOnchain) Stage.SENDING else Stage.SUBMITTED, prepared.attemptId, identity) + Logger.info("Reported a manual payment to the allowance ledger", context = TAG) + return prepared.attemptId + } + + suspend fun manualLightningPaymentSent(attemptId: String, paymentHash: String) { + val identity = activeIdentity ?: return + withContext(ioDispatcher) { + updateJournalEntry(attemptId, identity) { + it.copy(paymentHash = paymentHash.lowercase(), stage = Stage.SENT) + } + } + } + + suspend fun finishManualPayment( + attemptId: String, + outcome: PaymentOutcome, + transactionId: String? = null, + ) { + liveAttemptIds.remove(attemptId) + val identity = activeIdentity ?: return + withContext(ioDispatcher) { + settleMutex.withLock { + // The node's payment events may have settled a Lightning attempt already. + val stage = localState(identity).journal.firstOrNull { it.attemptId == attemptId }?.stage + if (stage == Stage.SUCCEEDED || stage == Stage.FAILED) return@withLock + transactionId?.let { txid -> updateJournalEntry(attemptId, identity) { it.copy(transactionId = txid) } } + runSuspendCatching { record(attemptId, outcome, identity) } + .onFailure { Logger.warn("Failed to record a manual payment outcome", it, context = TAG) } + } + } + } + + // endregion + + // region Helpers + + private fun checks( + request: PaykitPaymentRequest, + endpointIdentifier: String, + trustedTime: String, + ) = PaymentExecutionChecks( + trustedTime = trustedTime, + paymentEndpointIdentifier = endpointIdentifier, + actualAmount = accountingAmount(request.amountValue, PaykitIssuerInterop.BITCOIN_ASSET), + endpointCurrent = true, + localEnabled = true, + recurrenceEligible = true, + ) + + private suspend fun sendQueuedMessages(request: PaykitPaymentRequest) { + runSuspendCatching { + paykitSdkService.processOutboundPrivateMessages(request.counterparty) + }.onFailure { Logger.warn("Failed to send the automatic acceptance right away", it, context = TAG) } + } + + /** Journal writes before a handoff must be durable: a failed save throws, so no money moves unjournaled. */ + private suspend fun journal(entry: JournalEntry, identity: String) { + updateLocalStateDurably(identity) { state -> + state.copy( + journal = (state.journal.filterNot { it.attemptId == entry.attemptId } + entry) + .takeLast(MAX_JOURNAL_ENTRIES), + ) + } + } + + private suspend fun setStage(stage: Stage, attemptId: String, identity: String) { + updateLocalStateDurably(identity) { state -> + state.copy(journal = state.journal.map { if (it.attemptId == attemptId) it.copy(stage = stage) else it }) + } + } + + private suspend fun failUnsent( + request: PaykitPaymentRequest, + payment: PrivatePaykitAllowancePayment, + attemptId: String, + identity: String, + ) { + // Released only by whichever of this and the node's failure event takes the journaled version first. + if (takePaymentListVersion(attemptId, identity) != null) { + payer.releasePaymentList(request.counterparty, payment.context) + } + record(attemptId, PaymentOutcome.FAILED, identity) + } + + /** + * Clears and returns the payment list version an attempt consumed, so the list is released at most once: a second + * release could clear another payment's claim on the same version. + */ + private suspend fun takePaymentListVersion(attemptId: String, identity: String): JournalEntry? { + var taken: JournalEntry? = null + updateLocalState(identity) { state -> + state.copy( + journal = state.journal.map { + if (it.attemptId != attemptId) return@map it + taken = it.takeIf { entry -> entry.paymentListVersion != null } + it.copy(paymentListVersion = null, previousPaymentListVersion = null) + }, + ) + } + return taken + } + + private suspend fun updateJournalEntry( + attemptId: String, + identity: String, + change: (JournalEntry) -> JournalEntry, + ) { + updateLocalState(identity) { state -> + state.copy(journal = state.journal.map { if (it.attemptId == attemptId) change(it) else it }) + } + } + + private suspend fun releasePaymentList(entry: JournalEntry, identity: String) { + val taken = takePaymentListVersion(entry.attemptId, identity) ?: return + val context = PrivatePaykitPaymentContext( + paymentAppsByEndpoint = emptyMap(), + paymentListVersion = requireNotNull(taken.paymentListVersion), + previousPaymentListVersion = taken.previousPaymentListVersion, + ) + payer.releasePaymentList(entry.requestId.counterparty, context) + } + + private suspend fun notifyLimitReached(request: PaykitPaymentRequest, identity: String) { + var isNew = false + updateLocalState(identity) { state -> + isNew = request.paymentRequestId !in state.notifiedRequestIds + state.copy(notifiedRequestIds = state.notifiedRequestIds + request.paymentRequestId) + } + if (isNew) _events.tryEmit(PaykitAllowanceEvent.LimitReached(request.counterparty, request.amount.atomic)) + } + + // endregion +} + +private fun PaykitPaymentRequest.scope() = PaymentRequestScope(counterparty, paymentRequestId) + +/** + * Allowances pay only bitcoin-denominated requests; null for any other request. A conversion offered for other rails, + * such as USDT, does not change the bitcoin amount a Lightning payment sends. When it prices a bitcoin rail too, the + * SDK blocks the automatic payment and the request stays manual. + */ +internal fun PaykitPaymentRequest.allowanceSats(): ULong? = amount.atomic.takeIf { amount.asset == PaykitAsset.BTC } + +/** SDK rule codes for a request above an allowance's per-payment, period or lifetime limit. */ +private val CAPACITY_RULE_CODES = setOf( + "amount_outside_range", + "period_amount_limit", + "period_count_limit", + "lifetime_amount_limit", +) + +private fun AllowanceAccountingBlock?.isCapacityBlock(): Boolean = + this is AllowanceAccountingBlock.SharedRule && code in CAPACITY_RULE_CODES diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt new file mode 100644 index 0000000000..802b0ebe0f --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt @@ -0,0 +1,538 @@ +package to.bitkit.repositories + +import com.synonym.paykit.AllowanceFilter +import com.synonym.paykit.AllowanceHistoryStatus +import com.synonym.paykit.AllowanceLocalRole +import com.synonym.paykit.AllowanceRecord +import com.synonym.paykit.AllowanceTerms +import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.PaymentOutcome +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.Network +import to.bitkit.async.appScope +import to.bitkit.di.IoDispatcher +import to.bitkit.env.Env +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.models.safe +import to.bitkit.services.PaykitSdkService +import to.bitkit.utils.AppError +import to.bitkit.utils.Logger +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +sealed interface PaykitAllowanceEvent { + data class PaidAutomatically( + val counterparty: String, + val amountSats: ULong, + val paymentId: String, + ) : PaykitAllowanceEvent + + data class LimitReached(val counterparty: String, val amountSats: ULong) : PaykitAllowanceEvent + + data object LedgerChanged : PaykitAllowanceEvent +} + +enum class PaykitAllowanceAutoPayResult { + /** No accepted Allowance covers the request's contact, or the request is already being paid. */ + NOT_COVERED, + + /** An Allowance exists but this request stays on the manual flow (over a limit, ended, no payable endpoint). */ + MANUAL, + + /** The Lightning payment was handed to the node; the outcome arrives through the node's payment events. */ + STARTED, + + /** The payee has not published a payment list newer than the one last paid; the next refresh tries again. */ + DEFERRED, +} + +sealed class PaykitAllowanceError(message: String) : AppError(message) { + data object ContactNotLinked : PaykitAllowanceError("The contact has no linked Paykit identity") + data object Unavailable : PaykitAllowanceError("The allowance is unavailable") + data object PaymentAlreadyRecorded : PaykitAllowanceError("A payment for this request is already in progress") + data object PaymentListPending : PaykitAllowanceError("The payee has not published a new payment list yet") +} + +/** + * Allowances for the active identity. One user-facing entry is one grant to a contact's identity, which covers every + * Paykit app of that contact, and covered incoming requests are paid headlessly through + * [PaykitAllowanceExecutor]. The repository also settles its own Lightning attempts from the node's payment events + * and attributes automatic payments to the contact's activity. + */ +@Singleton +@Suppress("TooManyFunctions", "LongParameterList") +class PaykitAllowanceRepo @Inject constructor( + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val paykitSdkService: PaykitSdkService, + private val executor: PaykitAllowanceExecutor, + private val lightningRepo: LightningRepo, + private val activityRepo: ActivityRepo, + private val clock: Clock, +) { + companion object { + private const val TAG = "PaykitAllowanceRepo" + + /** A request created up to this long before its Allowance was accepted still counts as created after it. */ + val ACCEPTANCE_CLOCK_TOLERANCE = 30.seconds + + /** Endpoints Bitkit pays automatically: bolt11 and every on-chain method of the network. */ + fun allowedPaymentEndpointIdentifiers(network: Network = Env.network): List = + (listOf(MethodId.Bolt11) + MethodId.entries.filter { it.isOnchain }).map { it.rawValueForNetwork(network) } + } + + private val scope = appScope(ioDispatcher, TAG) + private val refreshMutex = Mutex() + private val publishLock = Any() + private val isProcessingRequests = AtomicBoolean(false) + private val updates = AtomicInteger(0) + + /** True while allowances are refreshing or requests are being admitted, when coverage may still change. */ + val isUpdating: Boolean get() = updates.get() > 0 + private val manualRequestSignatures = ConcurrentHashMap() + private val _allowances = MutableStateFlow>(emptyList()) + private val _localState = MutableStateFlow(PaykitAllowanceLocalState()) + private val _autoPaidRequestIds = MutableStateFlow>(emptySet()) + private var allowanceTerms: (PaykitAllowanceLimits, Instant, List) -> AllowanceTerms = + { limits, monthAnchor, endpoints -> limits.terms(monthAnchor, endpoints) } + + @Volatile + private var activeIdentity: String? = null + + private val _entries = MutableStateFlow>(emptyList()) + + /** Grants by contact identity. */ + val entries: StateFlow> = _entries.asStateFlow() + + private val _autoPaidSats = MutableStateFlow>(emptyMap()) + + /** Sats paid automatically per entry id, for "Paid so far". */ + val autoPaidSats: StateFlow> = _autoPaidSats.asStateFlow() + + val events: SharedFlow = executor.events + + internal constructor( + ioDispatcher: CoroutineDispatcher, + paykitSdkService: PaykitSdkService, + executor: PaykitAllowanceExecutor, + lightningRepo: LightningRepo, + activityRepo: ActivityRepo, + clock: Clock, + allowanceTerms: (PaykitAllowanceLimits, Instant, List) -> AllowanceTerms, + ) : this(ioDispatcher, paykitSdkService, executor, lightningRepo, activityRepo, clock) { + this.allowanceTerms = allowanceTerms + } + + init { + scope.launch { executor.events.collect { onAllowanceEvent(it) } } + scope.launch { lightningRepo.nodeEvents.collect { onNodeEvent(it) } } + scope.launch { + lightningRepo.lightningState + .map { it.nodeLifecycleState.isRunning() } + .distinctUntilChanged() + .filter { it } + .collect { executor.settleOpenLightningAttempts() } + } + } + + fun entry(id: String): PaykitAllowanceEntry? = _entries.value.firstOrNull { it.id == id } + + // region Lifecycle + + suspend fun activate(identity: String?) { + val normalizedIdentity = identity?.let(PubkyPublicKeyFormat::normalized) + if (normalizedIdentity == null) { + deactivate() + return + } + withContext(ioDispatcher) { + val identityChanged = activeIdentity != normalizedIdentity + activeIdentity = normalizedIdentity + executor.activate(normalizedIdentity) + if (identityChanged) { + manualRequestSignatures.clear() + executor.recover(normalizedIdentity) + } + refresh() + } + } + + fun deactivate() { + activeIdentity = null + executor.activate(null) + manualRequestSignatures.clear() + publish(emptyList(), PaykitAllowanceLocalState()) + } + + suspend fun refresh(): Result = withContext(ioDispatcher) { + val identity = activeIdentity ?: return@withContext Result.success(Unit) + whileUpdating { + refreshMutex.withLock { + val listing = listAllowances() + if (activeIdentity == identity) { + publish(listing.getOrDefault(_allowances.value), executor.localState(identity)) + } + listing.map {} + } + } + } + + private suspend fun whileUpdating(block: suspend () -> T): T { + val release = beginCoverageUpdate() + return try { + block() + } finally { + release() + } + } + + /** + * Holds [isUpdating] until the returned function runs, for a caller whose new requests and allowance changes arrive + * together: a request is not shown for manual payment before the allowances it may fall under are refreshed. + */ + fun beginCoverageUpdate(): () -> Unit { + updates.incrementAndGet() + val released = AtomicBoolean(false) + return { if (released.compareAndSet(false, true)) updates.decrementAndGet() } + } + + /** + * True when an allowance this wallet grants, in any state, names [request]'s contact: such a request may become + * covered by the refresh in progress, so it is not shown for manual payment meanwhile. + */ + fun mayCover(request: PaykitPaymentRequest): Boolean = request.requiresAcceptance && _allowances.value.any { + it.isAllower && PubkyPublicKeyFormat.matches(it.counterparty, request.counterparty) + } + + private suspend fun listAllowances(): Result> = runSuspendCatching { + paykitSdkService.listAllowances( + AllowanceFilter( + counterparty = null, + localRole = null, + states = emptyList(), + ), + ) + .filter { + it.historyStatus == AllowanceHistoryStatus.CONSISTENT || + it.historyStatus == AllowanceHistoryStatus.UNRESOLVED_REFERENCES + } + .mapNotNull { PaykitAllowance.from(it) } + }.onFailure { Logger.warn("Failed to list Paykit allowances", it, context = TAG) } + + /** Proposes the terms to the contact's identity, which needs a linked contact, and records one entry. */ + suspend fun propose(contactPublicKey: String, limits: PaykitAllowanceLimits): Result = + withContext(ioDispatcher) { + runSuspendCatching { + val identity = activeIdentity ?: throw PaykitAllowanceError.Unavailable + val contactKey = PubkyPublicKeyFormat.normalized(contactPublicKey) + ?: throw PaykitAllowanceError.ContactNotLinked + val isLinked = paykitSdkService.linkedPeers().any { + PubkyPublicKeyFormat.matches(it.counterparty, contactKey) && it.state == LinkedPeerState.LINKED + } + if (!isLinked) throw PaykitAllowanceError.ContactNotLinked + + val terms = allowanceTerms( + limits, + PaykitAllowanceTime.monthStart(clock.now()), + allowedPaymentEndpointIdentifiers(), + ) + val allowance = paykitSdkService.proposeAllowance(contactKey, AllowanceLocalRole.ALLOWER, terms) + sendQueuedMessages(contactKey) + val group = PaykitAllowanceLocalState.Group( + id = UUID.randomUUID().toString(), + counterparty = contactKey, + limits = limits, + allowanceIds = listOf(allowance.allowanceId), + createdAtMillis = clock.now().toEpochMilliseconds(), + ) + executor.updateLocalState(identity) { it.copy(groups = it.groups + group) } + Logger.info("Proposed an allowance", context = TAG) + refresh() + Unit + } + } + + suspend fun accept(entryId: String): Result = respond(entryId) { + val proposalStreamItemId = it.proposalStreamItemId ?: throw PaykitAllowanceError.Unavailable + paykitSdkService.acceptAllowance(it.counterparty, it.allowanceId, proposalStreamItemId) + } + + suspend fun reject(entryId: String): Result = respond(entryId) { + paykitSdkService.rejectAllowance(it.counterparty, it.allowanceId) + } + + suspend fun end(entryId: String): Result = withContext(ioDispatcher) { + runSuspendCatching { + val entry = entry(entryId) ?: throw PaykitAllowanceError.Unavailable + val endable = entry.allowances.filter { it.canEnd } + if (endable.isEmpty()) throw PaykitAllowanceError.Unavailable + for (allowance in endable) { + paykitSdkService.endAllowance(allowance.counterparty, allowance.allowanceId) + sendQueuedMessages(allowance.counterparty) + } + refresh() + Unit + } + } + + private suspend fun respond( + entryId: String, + response: suspend (PaykitAllowance) -> AllowanceRecord, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + val identity = activeIdentity ?: throw PaykitAllowanceError.Unavailable + val entry = entry(entryId) ?: throw PaykitAllowanceError.Unavailable + val answerable = entry.allowances.filter { it.isAnswerable } + if (answerable.isEmpty()) throw PaykitAllowanceError.Unavailable + for (allowance in answerable) { + response(allowance) + sendQueuedMessages(allowance.counterparty) + } + val keys = entry.allowances.map { it.key } + executor.updateLocalState(identity) { it.copy(presentedProposalIds = it.presentedProposalIds + keys) } + refresh() + Unit + } + } + + private suspend fun sendQueuedMessages(counterparty: String) { + runSuspendCatching { paykitSdkService.processOutboundPrivateMessages(counterparty) } + .onFailure { Logger.warn("Failed to send allowance messages right away", it, context = TAG) } + } + + // endregion + + // region Presentation + + /** The first received proposal that still needs an answer and was not shown yet. */ + fun proposalForPresentation(): PaykitAllowanceEntry? { + val presented = _localState.value.presentedProposalIds + return _entries.value.firstOrNull { entry -> + entry.isAnswerable && entry.allowances.none { it.key in presented } + } + } + + suspend fun markProposalPresented(entryId: String) { + val identity = activeIdentity ?: return + val keys = entry(entryId)?.allowances?.map { it.key } ?: return + val state = withContext(ioDispatcher) { + executor.updateLocalState(identity) { it.copy(presentedProposalIds = it.presentedProposalIds + keys) } + } + if (activeIdentity == identity) publish(_allowances.value, state) + } + + // endregion + + // region Automatic payments + + /** + * Whether an active Allowance this wallet granted covers the request's contact identity. A request created before + * the Allowance was accepted stays manual: it may already have been shown to the user for a decision. + */ + fun coversRequest(request: PaykitPaymentRequest): Boolean { + // Only bitcoin requests are paid automatically; anything else follows the manual flow at once. + if (request.allowanceSats() == null) return false + val now = clock.now() + return _allowances.value.any { allowance -> + allowance.isAllower && + allowance.status(now) == PaykitAllowance.Status.ACTIVE && + PubkyPublicKeyFormat.matches(allowance.counterparty, request.counterparty) && + isCreatedAfterAcceptance(request, allowance) + } + } + + /** Pays covered incoming requests headlessly; returns true when any request was handled. */ + suspend fun processIncomingRequests(requests: List): Boolean = withContext(ioDispatcher) { + val identity = activeIdentity ?: return@withContext false + val signature = allowancesSignature() + val covered = requests.filter { + it.requiresAcceptance && coversRequest(it) && manualRequestSignatures[it.id] != signature + } + if (covered.isEmpty()) return@withContext false + if (!canPayNow()) return@withContext markAboveLimitManual(covered, identity, signature) + if (!isProcessingRequests.compareAndSet(false, true)) return@withContext false + + try { + whileUpdating { payCovered(covered, identity, signature) } + } finally { + isProcessingRequests.set(false) + } + } + + /** + * True while a request is covered by an active allowance or is being paid automatically: keep it off the Send + * sheet. A covered request counts until processIncomingRequests has found it manual. + */ + suspend fun isAutomaticallyHandling(request: PaykitPaymentRequest): Boolean { + if (executor.isHandling(request.id)) return true + return request.requiresAcceptance && + coversRequest(request) && + manualRequestSignatures[request.id] != allowancesSignature() + } + + /** + * A node that just started lists its channels before they reconnect, and a payment sent then fails with no route. + * Covered requests wait for the next refresh instead of falling back to the manual flow. + */ + private fun canPayNow(): Boolean { + val state = lightningRepo.lightningState.value + return state.nodeLifecycleState.isRunning() && (state.channels.isEmpty() || state.channels.any { it.isUsable }) + } + + /** While the node cannot pay, a request that fits no allowance must not wait for channels: it asks at once. */ + private suspend fun markAboveLimitManual( + covered: List, + identity: String, + signature: Int, + ): Boolean { + var markedAny = false + for (request in covered) { + if (!executor.classifyAboveLimit(request, _allowances.value, identity)) continue + manualRequestSignatures[request.id] = signature + markedAny = true + } + return markedAny + } + + /** Request ids paid automatically, for the "Auto-paid" tag in payment history. */ + fun isAutoPaid(id: PaykitPaymentRequestId): Boolean = id in _autoPaidRequestIds.value + + private suspend fun payCovered( + covered: List, + identity: String, + signature: Int, + ): Boolean { + var handledAny = false + for (request in covered) { + val result = executor.autoPay(request, _allowances.value, identity) + Logger.info("Decided '$result' for an incoming request covered by an allowance", context = TAG) + when (result) { + PaykitAllowanceAutoPayResult.STARTED -> handledAny = true + PaykitAllowanceAutoPayResult.MANUAL -> manualRequestSignatures[request.id] = signature + PaykitAllowanceAutoPayResult.NOT_COVERED, PaykitAllowanceAutoPayResult.DEFERRED -> Unit + } + } + if (handledAny) refresh() + return handledAny + } + + private fun isCreatedAfterAcceptance(request: PaykitPaymentRequest, allowance: PaykitAllowance): Boolean { + // A request whose order against the acceptance cannot be proven may predate it, so it stays manual. + val acceptedAt = allowance.lastEventAt ?: return false + val createdAt = request.createdAt ?: return false + return createdAt >= acceptedAt - ACCEPTANCE_CLOCK_TOLERANCE + } + + private fun allowancesSignature(): Int { + val lifecycle = _allowances.value.map { it.allowanceId to it.lifecycleState } + val autoPaidTotal = _autoPaidSats.value.values.fold(0uL) { total, sats -> total.safe() + sats.safe() } + return listOf(lifecycle, autoPaidTotal).hashCode() + } + + // endregion + + // region Ledger + + /** + * Reports a manual payment of a request to the allowance ledger; returns the attempt id or null when no ledger + * applies. Fails with [PaykitAllowanceError.PaymentAlreadyRecorded] while another attempt for the request is live. + */ + suspend fun beginManualPayment(request: PaykitPaymentRequest, paymentEndpointIdentifier: String): Result = + executor.beginManualPayment(request, paymentEndpointIdentifier) + + suspend fun manualLightningPaymentSent(attemptId: String, paymentHash: String) = + executor.manualLightningPaymentSent(attemptId, paymentHash) + + /** [succeeded] null = outcome unknown (pending). */ + suspend fun finishManualPayment(attemptId: String, succeeded: Boolean?, transactionId: String? = null) { + val outcome = when (succeeded) { + true -> PaymentOutcome.SUCCEEDED + false -> PaymentOutcome.FAILED + null -> PaymentOutcome.UNKNOWN + } + executor.finishManualPayment(attemptId, outcome, transactionId) + } + + /** Whether [paymentHash] is an automatic Allowance payment, which no foreground send flow owns. */ + fun isAutomaticPayment(paymentHash: String): Boolean = executor.isAutomaticPayment(paymentHash) + + private suspend fun onNodeEvent(event: Event) { + when (event) { + is Event.PaymentSuccessful -> executor.lightningPaymentSettled(event.paymentHash, succeeded = true) + is Event.PaymentFailed -> (event.paymentHash ?: event.paymentId)?.let { + executor.lightningPaymentSettled(it, succeeded = false) + } + else -> Unit + } + } + + private suspend fun onAllowanceEvent(event: PaykitAllowanceEvent) { + when (event) { + is PaykitAllowanceEvent.PaidAutomatically -> { + activityRepo.setContact(event.counterparty, event.paymentId) + reloadLocalState() + } + PaykitAllowanceEvent.LedgerChanged -> reloadLocalState() + is PaykitAllowanceEvent.LimitReached -> Unit + } + } + + private fun reloadLocalState() { + val identity = activeIdentity ?: return + publish(_allowances.value, executor.localState(identity)) + } + + // endregion + + private fun publish( + allowances: List, + state: PaykitAllowanceLocalState, + ) = synchronized(publishLock) { + val entries = allowances + .groupBy { state.group(containing = it.allowanceId, counterparty = it.counterparty)?.id ?: it.key } + .map { (id, members) -> + PaykitAllowanceEntry( + id = id, + allowances = members, + limits = state.groups.firstOrNull { it.id == id }?.limits, + ) + } + val paid = state.journal.filter { it.isAutomatic && it.stage == PaykitAllowanceLocalState.Stage.SUCCEEDED } + // Allowance ids are unique per contact only, so payments count toward the contact's own allowance. + val paidByAllowance = paid.groupBy { PaykitAllowance.key(it.requestId.counterparty, it.allowanceId) } + .mapValues { (_, payments) -> + payments.fold(0uL) { total, payment -> total.safe() + payment.amountSats.safe() } + } + _allowances.update { allowances } + _localState.update { state } + _autoPaidRequestIds.update { paid.mapTo(mutableSetOf()) { it.requestId } } + _entries.update { entries } + _autoPaidSats.update { + entries.associate { entry -> + entry.id to entry.allowances.fold(0uL) { total, allowance -> + total.safe() + (paidByAllowance[allowance.key] ?: 0uL).safe() + } + } + } + } +} diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt new file mode 100644 index 0000000000..6b8a331f9c --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt @@ -0,0 +1,99 @@ +package to.bitkit.repositories + +import kotlinx.serialization.Serializable +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 to.bitkit.utils.Logger +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Local Allowance state kept per identity: USD labels, the grouping of one grant across a contact's links, and the + * execution journal that restart recovery reads. The SDK ledger stays authoritative for admission. + */ +@Serializable +data class PaykitAllowanceLocalState( + val groups: List = emptyList(), + val journal: List = emptyList(), + /** [PaykitAllowance.key]s of received proposals already shown. */ + val presentedProposalIds: Set = emptySet(), + val notifiedRequestIds: Set = emptySet(), + val lastTrustedTimeMillis: Long? = null, +) { + @Serializable + data class Group( + val id: String, + val counterparty: String, + val limits: PaykitAllowanceLimits, + val allowanceIds: List, + val createdAtMillis: Long, + ) + + @Serializable + enum class Stage { PREPARED, SUBMITTED, SENDING, SENT, SUCCEEDED, FAILED, UNKNOWN } + + @Serializable + data class JournalEntry( + val attemptId: String, + val isAutomatic: Boolean, + val requestId: PaykitPaymentRequestId, + val allowanceId: String?, + val amountSats: ULong, + val paymentEndpointIdentifier: String, + val paymentHash: String? = null, + val onchainAddress: String? = null, + val transactionId: String? = null, + val stage: Stage, + val createdAtMillis: Long, + /** The payee's private payment list this attempt consumed, given back if the payment fails. */ + val paymentListVersion: ULong? = null, + val previousPaymentListVersion: ULong? = null, + ) + + /** Allowance ids are unique per contact only, so a group matches on its contact as well. */ + fun group(containing: String, counterparty: String): Group? = groups.firstOrNull { + containing in it.allowanceIds && PubkyPublicKeyFormat.matches(it.counterparty, counterparty) + } +} + +@Singleton +class PaykitAllowanceStore @Inject constructor( + private val keychain: Keychain, +) { + companion object { + private const val TAG = "PaykitAllowanceStore" + private val KEY = Keychain.Key.PAYKIT_ALLOWANCE_STATE.name + private val storeJson = Json { ignoreUnknownKeys = true } + } + + @Serializable + private data class Stored( + val statesByIdentity: Map = emptyMap(), + ) + + fun load(identity: String): PaykitAllowanceLocalState = + runCatching { loadAll().statesByIdentity[storageKey(identity)] } + .onFailure { Logger.warn("Failed to load Paykit allowance state", it, context = TAG) } + .getOrNull() + ?: PaykitAllowanceLocalState() + + suspend fun save(identity: String, state: PaykitAllowanceLocalState) { + val stored = loadAll() + val updated = stored.copy(statesByIdentity = stored.statesByIdentity + (storageKey(identity) to state)) + keychain.upsertString(KEY, storeJson.encodeToString(updated)) + } + + private fun loadAll(): Stored { + val value = keychain.loadString(KEY) ?: return Stored() + return runCatching { storeJson.decodeFromString(value) } + .getOrElse { + Logger.warn("Discarded corrupt Paykit allowance state", it, context = TAG) + Stored() + } + } + + private fun storageKey(identity: String): String = PubkyPublicKeyFormat.normalized(identity) ?: identity +} diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index ce10f2eda3..47baa85a69 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -101,6 +101,8 @@ data class PendingPaykitPaymentProof( val hardwareTotalSpent: ULong? = null, val privatePaymentListVersion: ULong? = null, val previousPrivatePaymentListVersion: ULong? = null, + /** Set only for an automatic Allowance payment, from its submitted attempt. Manual payments omit it. */ + val allowanceId: String? = null, ) data class PaykitOnchainPaymentProofResolution( @@ -181,6 +183,7 @@ class PaykitPaymentProofRepo @Inject constructor( paymentAppId: String, kind: PaykitPaymentProofKind, paymentTerms: PaykitRequestPayment? = null, + allowanceId: String? = null, ): Result = withContext(ioDispatcher) { runSuspendCatching { releaseInterruptedShopPreparation() @@ -192,7 +195,7 @@ class PaykitPaymentProofRepo @Inject constructor( paymentEndpointIdentifier, paymentAppId, kind - ).copy(conversionQuoteId = paymentTerms?.quoteId) + ).copy(conversionQuoteId = paymentTerms?.quoteId, allowanceId = allowanceId) val currentProofs = loadProofs() if (currentProofs.any { it.isStartedFor(proof.identity, request.id) || @@ -579,6 +582,23 @@ class PaykitPaymentProofRepo @Inject constructor( completeOnchainPayment(request, txid, proof.paymentEndpointIdentifier, proof.paymentAppId) } + /** Whether a verified on-chain proof of [txid] is held for [requestId], here or already queued in the SDK. */ + suspend fun hasVerifiedOnchainProof(requestId: PaykitPaymentRequestId, txid: String): Boolean = + withContext(ioDispatcher) { + val held = operationMutex.withLock { + loadProofs().any { + it.requestId == requestId && it.kind == PaykitPaymentProofKind.Onchain && + it.onchainAcceptanceVerified == true && it.proofData.equals(txid, ignoreCase = true) + } + } + val expected = proofJson(PaykitPaymentProofKind.Onchain, txid.lowercase()).proofValues() + held || paykitSdkService.paymentRequests().any { record -> + record.paymentRequestId == requestId.paymentRequestId && + PubkyPublicKeyFormat.matches(record.counterparty, requestId.counterparty) && + record.paymentProofs.any { it.proof.exportText().proofValues() == expected } + } + } + @Suppress("CyclomaticComplexMethod") suspend fun completeOnchainPayment( request: PaykitPaymentRequest, @@ -1409,6 +1429,7 @@ class PaykitPaymentProofRepo @Inject constructor( proofJson = proofJson, conversionQuoteId = proof.conversionQuoteId, billingPeriod = proof.billingPeriod, + allowanceId = proof.allowanceId, ) Logger.info("Queued a Paykit payment proof for private delivery", context = TAG) runSuspendCatching { paykitSdkService.processPendingPrivateMessages() } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 430448a937..3732a6717f 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -1063,35 +1063,13 @@ class PaykitPaymentRequestRepo @Inject constructor( if (it.proposalStreamItemId != proposalStreamItemId) { throw PaykitPaymentRequestError.RequestUnavailable } - val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable - val generation = stateGeneration.get() - ensurePaymentAllowed(it, forExecution = false).getOrThrow() - val alreadySaved = it.id in presentationStore.loadAcceptedOneTimeIds(identity) - val acceptedIds = presentationStore.addAcceptedOneTimeId(identity, it.id) - if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable - acceptedOneTimeRequestIds = acceptedIds - runSuspendCatching { + withAcceptanceIntent(it) { paykitSdkService.acceptPaymentRequest( counterparty = it.counterparty, paymentRequestId = it.paymentRequestId, expectedProposalStreamItemId = proposalStreamItemId, ) - }.onFailure { error -> - // Acceptance may already be committed. Reconcile before discarding its owner. - val uncertain = when (error) { - is PaykitException.Transport, - is PaykitException.Storage, - is PaykitException.Identity, - is PaykitException.ConcurrentUpdate, - is PaykitException.SharedStateBusy -> true - else -> false - } - if (!alreadySaved && !uncertain) { - val remaining = presentationStore.removeAcceptedOneTimeIds(identity, setOf(it.id)) - if (isCurrentState(generation, identity)) acceptedOneTimeRequestIds = remaining - } - }.getOrThrow() - if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable + } }.getOrThrow() } }.onFailure { @@ -1099,6 +1077,55 @@ class PaykitPaymentRequestRepo @Inject constructor( } } + /** + * Accepts [request] through [accept] with this install as its owner, like [accept], for an acceptance that does not + * come from the request sheet: the Allowance accepts an automatically paid request. The request stays payable on + * this install if the payment that follows fails. + */ + suspend fun acceptOnThisInstall( + request: PaykitPaymentRequest, + accept: suspend () -> T, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + if (!request.requiresAcceptance) throw PaykitPaymentRequestError.RequestUnavailable + operationMutex.withLock { withAcceptanceIntent(request, accept) } + }.onFailure { + Logger.warn("Failed to accept an incoming Paykit payment request for this install", it, context = TAG) + } + } + + /** Saves this install as the owner of [request] before [operation] runs, and drops it on a definite failure. */ + private suspend fun withAcceptanceIntent(request: PaykitPaymentRequest, operation: suspend () -> T): T { + val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable + val generation = stateGeneration.get() + ensurePaymentAllowed(request, forExecution = false).getOrThrow() + val alreadySaved = request.id in presentationStore.loadAcceptedOneTimeIds(identity) + val acceptedIds = presentationStore.addAcceptedOneTimeId(identity, request.id) + requireCurrentState(generation, identity) + acceptedOneTimeRequestIds = acceptedIds + val result = runSuspendCatching { operation() }.onFailure { error -> + // Acceptance may already be committed. Reconcile before discarding its owner. + val uncertain = when (error) { + is PaykitException.Transport, + is PaykitException.Storage, + is PaykitException.Identity, + is PaykitException.ConcurrentUpdate, + is PaykitException.SharedStateBusy -> true + else -> false + } + if (!alreadySaved && !uncertain) { + val remaining = presentationStore.removeAcceptedOneTimeIds(identity, setOf(request.id)) + if (isCurrentState(generation, identity)) acceptedOneTimeRequestIds = remaining + } + }.getOrThrow() + requireCurrentState(generation, identity) + return result + } + + private fun requireCurrentState(generation: Long, identity: String) { + if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable + } + suspend fun reject(request: PaykitPaymentRequest): Result = updateRequest( request = request, resultingState = PaymentRequestLifecycleState.REJECTED, diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 3670e42bba..7881e9e96d 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -564,6 +564,94 @@ class PrivatePaykitRepo @Inject constructor( result } + /** + * Resolves the payee's current private endpoint for an automatic Allowance payment. Only endpoints that the + * Allowance and the request both accept qualify, and only ones this wallet can pay without asking: a bolt11 + * invoice for exactly the requested amount (or amount-less), or an unused on-chain address. Public endpoints + * are never used. Returns null when nothing qualifies. + */ + suspend fun resolveAllowancePayment( + request: PaykitPaymentRequest, + eligibleIdentifiers: List, + ): Result = withContext(serializedDispatcher) { + runSuspendCatching { + if (request.isExpired(clock.now())) return@runSuspendCatching null + val publicKey = normalizedPublicKey(request.counterparty) ?: return@runSuspendCatching null + val consumedVersion = ensureState().contacts[publicKey]?.consumedPrivatePaymentListVersion + val prepared = paykitSdkService.prepareAndResolvePrivatePaymentRequest( + counterparty = publicKey, + paymentRequestId = request.paymentRequestId, + afterPrivatePaymentListVersion = consumedVersion, + ) + val resolution = prepared.resolution + if ( + resolution.state == PrivatePaymentResolutionState.RECOVERY_PENDING || + resolution.status == PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST + ) { + // The last list was already paid from; a new one arrives once the payee sees that payment settle. + schedulePendingPrivateMessageDrainRetries( + reason = "allowance payment", + retryKeys = listOf(publicKey), + ) + throw PaykitAllowanceError.PaymentListPending + } + val paymentListVersion = resolution.privatePaymentListVersion ?: return@runSuspendCatching null + + val eligible = eligibleIdentifiers.toSet() intersect request.acceptedPaymentEndpointIdentifiers.toSet() + val candidates = prepared.resolution.payableEndpoints + .mapNotNull { PublicPaykitRepo.parseEndpoint(it.identifier, it.payload)?.copy(appId = it.appId) } + .filter { + it.methodId.rawValue in eligible && (it.methodId == MethodId.Bolt11 || it.methodId.isOnchain) + } + val payable = privatePayableEndpoints(candidates, publicKey, allowUsedOnchainAddress = false) + allowancePayment( + request = request, + payable = payable, + paymentListVersion = paymentListVersion, + ) + } + } + + private suspend fun allowancePayment( + request: PaykitPaymentRequest, + payable: List, + paymentListVersion: ULong, + ): PrivatePaykitAllowancePayment? = PublicPaykitRepo.payablePreferenceOrder + .mapNotNull { methodId -> payable.firstOrNull { it.methodId == methodId } } + .firstNotNullOfOrNull { allowancePayment(request, it, paymentListVersion) } + + private suspend fun allowancePayment( + request: PaykitPaymentRequest, + endpoint: Endpoint, + paymentListVersion: ULong, + ): PrivatePaykitAllowancePayment? { + val appId = endpoint.appId ?: return null + val context = PrivatePaykitPaymentContext( + paymentAppsByEndpoint = mapOf(endpoint.methodId.rawValue to appId), + paymentListVersion = paymentListVersion, + ) + if (endpoint.methodId != MethodId.Bolt11) { + return PrivatePaykitAllowancePayment( + endpoint = endpoint, + appId = appId, + context = context, + lightningPaymentHash = null, + lightningInvoiceHasAmount = false, + ) + } + val invoice = runSuspendCatching { (coreService.decode(endpoint.value) as? Scanner.Lightning)?.invoice } + .getOrNull() + ?: return null + if (!request.acceptsLightningInvoiceAmountSats(invoice.amountSatoshis)) return null + return PrivatePaykitAllowancePayment( + endpoint = endpoint, + appId = appId, + context = context, + lightningPaymentHash = invoice.paymentHash.toHex().lowercase(), + lightningInvoiceHasAmount = invoice.amountSatoshis != 0uL, + ) + } + suspend fun consumePrivatePaymentList( publicKey: String, context: PrivatePaykitPaymentContext, diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 21623168ed..1873af4a86 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -96,6 +96,16 @@ data class PrivatePaykitPaymentContext( val previousPaymentListVersion: ULong? = null, ) +/** The payee's current private endpoint for an automatic Allowance payment. */ +data class PrivatePaykitAllowancePayment( + val endpoint: Endpoint, + /** The payee Paykit app that owns [endpoint]; the payment proof names it. */ + val appId: String, + val context: PrivatePaykitPaymentContext, + val lightningPaymentHash: String?, + val lightningInvoiceHasAmount: Boolean, +) + @OptIn(ExperimentalTime::class) @Singleton class PublicPaykitRepo @Inject constructor( diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index eeddf093ea..78a272c4c9 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -46,6 +46,7 @@ import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.PaymentKind import org.lightningdevkit.ldknode.PeerDetails import org.lightningdevkit.ldknode.PublicKey +import org.lightningdevkit.ldknode.RouteParametersConfig import org.lightningdevkit.ldknode.ScoringFeeParameters import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.TransactionDetails @@ -166,6 +167,11 @@ class LightningService internal constructor( private const val RELEASE_AMOUNT_SATS = 1_000uL private const val NODE_ID_PREVIEW_LEN = 20 + /** LDK's defaults for route parameters, kept when only the routing fee is capped. */ + private const val DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA = 1008u + private const val DEFAULT_MAX_PATH_COUNT: UByte = 10u + private const val DEFAULT_MAX_CHANNEL_SATURATION_POWER_OF_HALF: UByte = 2u + private const val SCORING_BASE_PENALTY_MSAT = 40_000uL private const val SCORING_LIQUIDITY_PENALTY_MULTIPLIER_MSAT = 10_000uL private const val SCORING_LIQUIDITY_PENALTY_AMOUNT_MULTIPLIER_MSAT = 10_000uL @@ -1111,7 +1117,12 @@ class LightningService internal constructor( internal suspend fun callOnchainSend(block: suspend () -> T): T = ServiceQueue.LDK.background(ldkQueue) { runSuspendCatching { block() } }.getOrThrow() - suspend fun send(bolt11: String, sats: ULong? = null, paymentDeadlineAt: Instant? = null): PaymentId { + suspend fun send( + bolt11: String, + sats: ULong? = null, + paymentDeadlineAt: Instant? = null, + maxTotalRoutingFeeMsat: ULong? = null, + ): PaymentId { val node = this.node ?: throw ServiceError.NodeNotSetup() Logger.debug("Paying bolt11: $bolt11", context = TAG) @@ -1122,9 +1133,10 @@ class LightningService internal constructor( return ServiceQueue.LDK.background(ldkQueue) { ensurePaymentDeadline(paymentDeadlineAt) runCatching { + val routeParameters = maxTotalRoutingFeeMsat?.let(::routeParametersWithFeeCap) when (sats != null) { - true -> node.bolt11Payment().sendUsingAmount(bolt11Invoice, sats * 1000u, null) - else -> node.bolt11Payment().send(bolt11Invoice, null) + true -> node.bolt11Payment().sendUsingAmount(bolt11Invoice, sats * 1000u, routeParameters) + else -> node.bolt11Payment().send(bolt11Invoice, routeParameters) } } }.onFailure { @@ -1132,6 +1144,14 @@ class LightningService internal constructor( }.getOrThrow() } + /** LDK's default route parameters with the total routing fee capped at [maxTotalRoutingFeeMsat]. */ + private fun routeParametersWithFeeCap(maxTotalRoutingFeeMsat: ULong) = RouteParametersConfig( + maxTotalRoutingFeeMsat = maxTotalRoutingFeeMsat, + maxTotalCltvExpiryDelta = DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA, + maxPathCount = DEFAULT_MAX_PATH_COUNT, + maxChannelSaturationPowerOfHalf = DEFAULT_MAX_CHANNEL_SATURATION_POWER_OF_HALF, + ) + private fun ensurePaymentDeadline(paymentDeadlineAt: Instant?) { if (paymentDeadlineAt != null && clock.now() > paymentDeadlineAt) { throw ServiceError.PaymentDeadlineExpired() diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 992b8aef32..fd11310c49 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -2,6 +2,15 @@ package to.bitkit.services import android.content.Context import androidx.annotation.VisibleForTesting +import com.synonym.paykit.AllowanceAccountingReconciliation +import com.synonym.paykit.AllowanceAccountingState +import com.synonym.paykit.AllowanceAssociationRecord +import com.synonym.paykit.AllowanceCandidate +import com.synonym.paykit.AllowanceFilter +import com.synonym.paykit.AllowanceLocalRole +import com.synonym.paykit.AllowanceRecord +import com.synonym.paykit.AllowanceSelectionInput +import com.synonym.paykit.AllowanceTerms import com.synonym.paykit.ContactRecord import com.synonym.paykit.ContactUpdate import com.synonym.paykit.EndpointSyncReport @@ -20,8 +29,14 @@ import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PaykitSdkDefaults import com.synonym.paykit.PaykitStateObservation import com.synonym.paykit.PaymentAmountContext +import com.synonym.paykit.PaymentAttemptDecision +import com.synonym.paykit.PaymentAttemptRecord import com.synonym.paykit.PaymentConversion import com.synonym.paykit.PaymentDeadline +import com.synonym.paykit.PaymentExecutionChecks +import com.synonym.paykit.PaymentOccurrence +import com.synonym.paykit.PaymentOccurrenceRecord +import com.synonym.paykit.PaymentOutcomeReport import com.synonym.paykit.PaymentPayload import com.synonym.paykit.PaymentProofSubmission import com.synonym.paykit.PaymentReference @@ -31,6 +46,7 @@ import com.synonym.paykit.PaymentRequestLifecycleState import com.synonym.paykit.PaymentRequestLocalRole import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestRecurrence +import com.synonym.paykit.PaymentRequestScope import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PaymentTarget import com.synonym.paykit.PrivateContactPaymentResolution @@ -1147,6 +1163,7 @@ class PaykitSdkService @Inject constructor( proofJson: String, billingPeriod: PaykitBillingPeriod? = null, conversionQuoteId: String? = null, + allowanceId: String? = null, ): PaymentRequestRecord { isSetup.await() return operationLock.withLock { @@ -1159,7 +1176,7 @@ class PaykitSdkService @Inject constructor( billingPeriod = billingPeriod?.sdkValue, paymentAppId = paymentAppId, paymentEndpointIdentifier = paymentEndpointIdentifier, - allowanceId = null, + allowanceId = allowanceId, conversionQuoteId = conversionQuoteId, proof = PrivateJsonObject(proofJson), ), @@ -1169,6 +1186,159 @@ class PaykitSdkService @Inject constructor( } } + suspend fun listAllowances(filter: AllowanceFilter): List { + isSetup.await() + return operationLock.withLock { + withPaykitKey { completeSdkCall { it.listAllowances(filter) } } + } + } + + suspend fun proposeAllowance( + counterparty: String, + localRole: AllowanceLocalRole, + terms: AllowanceTerms, + ): AllowanceRecord { + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { handle -> + completeSdkCall { handle.proposeAllowance(counterparty, localRole, terms) } + } + } + } + + suspend fun acceptAllowance( + counterparty: String, + allowanceId: String, + expectedProposalStreamItemId: ULong, + ): AllowanceRecord { + isSetup.await() + return operationLock.withLock(Priority.Interactive) { + withStateRevisionTracking { handle -> + completeSdkCall { handle.acceptAllowance(counterparty, allowanceId, expectedProposalStreamItemId) } + } + } + } + + suspend fun rejectAllowance( + counterparty: String, + allowanceId: String, + ): AllowanceRecord { + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { handle -> + completeSdkCall { handle.rejectAllowance(counterparty, allowanceId) } + } + } + } + + suspend fun endAllowance( + counterparty: String, + allowanceId: String, + ): AllowanceRecord { + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { handle -> + completeSdkCall { handle.endAllowance(counterparty, allowanceId) } + } + } + } + + suspend fun allowanceAccountingState(): AllowanceAccountingState? { + isSetup.await() + return operationLock.withLock { + withPaykitKey { completeSdkCall { it.allowanceAccountingState() } } + } + } + + suspend fun reconcileAllowanceAccounting( + reconciliation: AllowanceAccountingReconciliation, + ): AllowanceAccountingState { + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { handle -> + completeSdkCall { handle.reconcileAllowanceAccounting(reconciliation) } + } + } + } + + suspend fun evaluateAllowanceCandidates( + scope: PaymentRequestScope, + trustedTime: String, + ): List { + isSetup.await() + return operationLock.withLock { + withPaykitKey { completeSdkCall { it.evaluateAllowanceCandidates(scope, trustedTime) } } + } + } + + suspend fun acceptPaymentRequestAutomatically( + scope: PaymentRequestScope, + selection: AllowanceSelectionInput, + checks: PaymentExecutionChecks, + ): AllowanceAssociationRecord { + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { handle -> + completeSdkCall { handle.acceptPaymentRequestAutomatically(scope, selection, checks) } + } + } + } + + suspend fun reserveAutomaticPayment( + occurrence: PaymentOccurrence, + expectedAssociationRevision: ULong, + checks: PaymentExecutionChecks, + ): PaymentAttemptDecision { + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { handle -> + completeSdkCall { handle.reserveAutomaticPayment(occurrence, expectedAssociationRevision, checks) } + } + } + } + + suspend fun reserveManualPayment( + occurrence: PaymentOccurrence, + checks: PaymentExecutionChecks, + ): PaymentAttemptDecision { + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { handle -> + completeSdkCall { handle.reserveManualPayment(occurrence, checks) } + } + } + } + + suspend fun beginPaymentExecution( + attemptId: String, + checks: PaymentExecutionChecks, + ): PaymentAttemptDecision { + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { handle -> + completeSdkCall { handle.beginPaymentExecution(attemptId, checks) } + } + } + } + + suspend fun recordPaymentOutcome(report: PaymentOutcomeReport): PaymentAttemptRecord { + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { handle -> + completeSdkCall { handle.recordPaymentOutcome(report) } + } + } + } + + suspend fun markPaymentManualOnly(occurrence: PaymentOccurrence): PaymentOccurrenceRecord { + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { handle -> + completeSdkCall { handle.markPaymentManualOnly(occurrence) } + } + } + } + suspend fun rejectPaymentRequest( counterparty: String, paymentRequestId: String, diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 826dba5c36..fff4a35de1 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -127,6 +127,7 @@ 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.AllowanceSheet import to.bitkit.ui.screens.subscriptions.CreateSubscriptionSheet import to.bitkit.ui.screens.subscriptions.SubscriptionDetailScreen import to.bitkit.ui.screens.subscriptions.SubscriptionSheet @@ -567,7 +568,7 @@ fun ContentView( usesFlatSheetStyle || currentSheet is Sheet.Widgets -> Colors.Gray7 // Meet the top of the subscription sheets' own gradient, so the grabber strip // does not sit a shade darker than the content right below it. - currentSheet is Sheet.Subscription -> Colors.Gray6 + currentSheet is Sheet.Subscription || currentSheet is Sheet.Allowance -> Colors.Gray6 else -> DefaultSheetContainerColor }, sheets = { @@ -625,6 +626,8 @@ fun ContentView( is Sheet.Subscription -> SubscriptionSheet(appViewModel, sheet.route) + is Sheet.Allowance -> AllowanceSheet(appViewModel, sheet.route) + is Sheet.ActivityDateRangeSelector -> DateRangeSelectorSheet() is Sheet.ActivityTagSelector -> TagSelectorSheet() is Sheet.Pin -> PinSheet(sheet, appViewModel) 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 e23357e991..ff1b102332 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -56,6 +56,12 @@ enum class SheetHandlePlacement { ContentOverlay, } +sealed interface AllowanceRoute { + data object Set : AllowanceRoute + data class Review(val entryId: String) : AllowanceRoute + data class Details(val entryId: String) : AllowanceRoute +} + sealed interface SubscriptionRoute { data class Review(val id: PaykitSubscriptionId) : SubscriptionRoute data class Success(val id: PaykitSubscriptionId) : SubscriptionRoute @@ -79,6 +85,7 @@ sealed interface Sheet { data object PaymentRequests : Sheet data object CreateSubscription : Sheet data class Subscription(val route: SubscriptionRoute) : Sheet + data class Allowance(val route: AllowanceRoute) : Sheet data class Pin(val route: PinRoute = PinRoute.Prompt()) : Sheet data object ChangePin : Sheet data object DisablePin : Sheet diff --git a/app/src/main/java/to/bitkit/ui/components/Slider.kt b/app/src/main/java/to/bitkit/ui/components/Slider.kt index 22f8b3d0db..c9531d61fb 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -13,7 +13,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.systemGestureExclusion import androidx.compose.runtime.Composable @@ -32,12 +32,14 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.ProgressBarRangeInfo import androidx.compose.ui.semantics.progressBarRangeInfo import androidx.compose.ui.semantics.semantics @@ -51,6 +53,7 @@ import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.launch +import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import kotlin.math.abs @@ -72,6 +75,9 @@ fun Slider( onValueChange: (Int) -> Unit, modifier: Modifier = Modifier, formatLabel: (Int) -> String = { "$$it" }, + activeColor: Color = Colors.Green, + trackColor: Color = Colors.Green32, + stopTestTag: ((index: Int) -> String)? = null, ) { val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() @@ -172,7 +178,7 @@ fun Slider( val cornerRadius = density.run { 3.dp.toPx() } drawRoundRect( - color = Colors.Green32, + color = trackColor, topLeft = Offset(0f, trackY - trackHeight / 2), size = Size(size.width, trackHeight), cornerRadius = CornerRadius(cornerRadius), @@ -180,7 +186,7 @@ fun Slider( if (knobX > 0f) { drawRoundRect( - color = Colors.Green, + color = activeColor, topLeft = Offset(0f, trackY - trackHeight / 2), size = Size(knobX, trackHeight), cornerRadius = CornerRadius(cornerRadius), @@ -248,7 +254,7 @@ fun Slider( modifier = Modifier .size(KNOB_SIZE_DP.dp) .clip(CircleShape) - .background(Colors.Green) + .background(activeColor) ) { Box( modifier = Modifier @@ -266,6 +272,8 @@ fun Slider( StepSliderLabels( steps = steps, formatLabel = formatLabel, + stopTestTag = stopTestTag, + onStepClick = { onValueChange(steps[it]) }, ) }.first().measure(Constraints.fixedWidth(width)) @@ -302,17 +310,28 @@ private fun Modifier.stepSliderSemantics( private fun StepSliderLabels( steps: ImmutableList, formatLabel: (Int) -> String, + stopTestTag: ((index: Int) -> String)?, + onStepClick: (index: Int) -> Unit, modifier: Modifier = Modifier, ) { Layout( modifier = modifier, content = { - steps.forEach { step -> + steps.forEachIndexed { index, step -> Caption13Up( text = formatLabel(step), color = Colors.White64, textAlign = TextAlign.Center, - modifier = Modifier.width(KNOB_SIZE_DP.dp) + maxLines = 1, + modifier = Modifier + .widthIn(min = KNOB_SIZE_DP.dp) + .then( + stopTestTag?.let { tag -> + Modifier + .clickableAlpha { onStepClick(index) } + .testTag(tag(index)) + } ?: Modifier + ) ) } }, 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 e1e04ed29f..13256301d1 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 @@ -201,6 +201,7 @@ fun PaymentRequestsScreen( onDetails: (PaykitPaymentRequestId) -> Unit, showsNavigationBar: Boolean = true, topPadding: Dp = 0.dp, + autoPaidRequestIds: ImmutableSet = persistentSetOf(), ) { val pending by appViewModel.pendingPaymentRequests.collectAsStateWithLifecycle() val receipts by appViewModel.paykitUsdtPayments.receipts.collectAsStateWithLifecycle() @@ -217,6 +218,7 @@ fun PaymentRequestsScreen( contacts = contacts.toImmutableList(), subscriptions = subscriptions.toImmutableList(), dismissingRequestIds = dismissingRequestIds.toImmutableSet(), + autoPaidRequestIds = autoPaidRequestIds, canRequestPayment = targets.isNotEmpty(), onBack = onBack, onRequestPayment = onRequestPayment, @@ -245,6 +247,7 @@ internal fun PaymentRequestsContent( onDetails: (PaykitPaymentRequestId) -> Unit, showsNavigationBar: Boolean = true, topPadding: Dp = 0.dp, + autoPaidRequestIds: ImmutableSet = persistentSetOf(), ) { val sections = paymentRequestSections(requests, pending, Clock.System.now()) val density = LocalDensity.current @@ -338,9 +341,12 @@ internal fun PaymentRequestsContent( request = request, contact = contacts.contactFor(request), compactSubtitle = if (request.hasPaymentEvidence) { - subscriptions.nameFor(request) - ?: request.note?.takeIf(String::isNotBlank) - ?: paymentRequestDate(request) + paymentRequestHistorySubtitle( + subtitle = subscriptions.nameFor(request) + ?: request.note?.takeIf(String::isNotBlank) + ?: paymentRequestDate(request), + isAutoPaid = request.id in autoPaidRequestIds, + ) } else { paymentRequestStatus(request) }, @@ -479,6 +485,12 @@ private fun PaykitPaymentRequest.historyPeriod( } } +@Composable +private fun paymentRequestHistorySubtitle(subtitle: String, isAutoPaid: Boolean): String { + if (!isAutoPaid) return subtitle + return "$subtitle · ${stringResource(R.string.subscriptions__allowance_auto_paid)}" +} + @Composable internal fun paymentRequestDate(request: PaykitPaymentRequest): String = request.createdAt?.let { uiDateText(it.epochSeconds.toULong(), UiDateStyle.DATE) diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowanceSheet.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowanceSheet.kt new file mode 100644 index 0000000000..6bc695b0fa --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowanceSheet.kt @@ -0,0 +1,620 @@ +package to.bitkit.ui.screens.subscriptions + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay +import to.bitkit.R +import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.PaykitAllowance +import to.bitkit.repositories.PaykitAllowanceLimits +import to.bitkit.ui.components.AllowanceRoute +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.BottomSheetPreview +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.MoneyCaptionB +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.PubkyContactAvatar +import to.bitkit.ui.components.PubkyContactRow +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.Slider +import to.bitkit.ui.components.SwipeToConfirm +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.screens.wallets.usdt.UsdtAuthentication +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.AppViewModel +import kotlin.time.Duration.Companion.seconds + +/** Delay before a shown proposal counts as seen: another sheet's dismissal can close this one right after it opens. */ +private val PROPOSAL_SEEN_DELAY = 1.seconds + +private const val DEFAULT_PER_PAYMENT_INDEX = 1 +private const val DEFAULT_MONTHLY_INDEX = 2 + +@Composable +fun AllowanceSheet( + appViewModel: AppViewModel, + route: AllowanceRoute, + viewModel: AllowancesViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + // The view model outlives this sheet, so a check left open must never run in the next one. + DisposableEffect(route) { + onDispose { viewModel.onAuthorizationCancelled() } + } + + if (!uiState.isLoaded) { + Box( + modifier = Modifier + .fillMaxWidth() + .sheetHeight() + .gradientBackground(startColor = Colors.Gray6, endColor = Colors.Black) + ) + return + } + + // A cancelled check resets the swiped End control, which otherwise stays at its confirmed end. + var authorizationCancels by remember { mutableIntStateOf(0) } + val onAuthorizationCancel: () -> Unit = { + viewModel.onAuthorizationCancelled() + authorizationCancels++ + } + Box { + when (route) { + AllowanceRoute.Set -> AllowanceSetFlow( + uiState = uiState, + onSave = { contact, perPaymentUsd, monthlyUsd -> + viewModel.propose(contact, perPaymentUsd, monthlyUsd, onSuccess = appViewModel::hideSheet) + }, + ) + + is AllowanceRoute.Review -> { + val allowance = uiState.allowances.firstOrNull { it.id == route.entryId } + if (allowance == null) { + AllowanceUnavailableContent(onClose = appViewModel::hideSheet, modifier = Modifier.sheetHeight()) + } else { + LaunchedEffect(route.entryId) { + delay(PROPOSAL_SEEN_DELAY) + viewModel.markProposalPresented(route.entryId) + } + AllowanceReviewContent( + allowance = allowance, + isWorking = uiState.isWorking, + onClickDecline = { viewModel.decline(allowance.id, onSuccess = appViewModel::hideSheet) }, + onClickAccept = { viewModel.accept(allowance.id, onSuccess = appViewModel::hideSheet) }, + modifier = Modifier.sheetHeight() + ) + } + } + + is AllowanceRoute.Details -> { + val allowance = uiState.allowances.firstOrNull { it.id == route.entryId } + if (allowance == null) { + AllowanceUnavailableContent(onClose = appViewModel::hideSheet, modifier = Modifier.sheetHeight()) + } else { + key(authorizationCancels) { + AllowanceDetailContent( + allowance = allowance, + isWorking = uiState.isWorking, + onEnd = { viewModel.end(allowance.id, onSuccess = appViewModel::hideSheet) }, + modifier = Modifier.sheetHeight() + ) + } + } + } + } + uiState.authorization?.let { + BackHandler(onBack = onAuthorizationCancel) + Box( + modifier = Modifier + .matchParentSize() + .gradientBackground(startColor = Colors.Gray6, endColor = Colors.Black) + ) { + UsdtAuthentication( + useBiometrics = it.useBiometrics, + onCancel = onAuthorizationCancel, + onVerified = viewModel::onAuthorized, + ) + } + } + } +} + +@Composable +private fun AllowanceSetFlow( + uiState: AllowancesUiState, + onSave: (contact: PubkyProfile, perPaymentUsd: Int, monthlyUsd: Int) -> Unit, +) { + var contactKey by rememberSaveable { mutableStateOf(null) } + val contact = uiState.contacts.firstOrNull { it.publicKey == contactKey } + + if (contact == null) { + AllowanceContactContent( + contacts = uiState.contacts, + onClickContact = { contactKey = it.publicKey }, + modifier = Modifier.sheetHeight() + ) + } else { + SetAllowanceContent( + contact = contact, + isWorking = uiState.isWorking, + onBack = { contactKey = null }, + onClickSave = { perPaymentUsd, monthlyUsd -> onSave(contact, perPaymentUsd, monthlyUsd) }, + modifier = Modifier.sheetHeight() + ) + } +} + +@Composable +private fun AllowanceContactContent( + contacts: ImmutableList, + onClickContact: (PubkyProfile) -> Unit, + modifier: Modifier = Modifier, +) { + AllowanceSheetColumn(modifier = modifier) { + SheetTopBar(titleText = stringResource(R.string.subscriptions__allowance_choose_contact)) + if (contacts.isEmpty()) { + VerticalSpacer(16.dp) + BodyM( + text = stringResource(R.string.subscriptions__allowance_no_contacts), + color = Colors.White64, + ) + FillHeight() + } else { + LazyColumn(modifier = Modifier.weight(1f)) { + items(contacts, key = { it.publicKey }) { contact -> + PubkyContactRow( + profile = contact, + onClick = { onClickContact(contact) }, + modifier = Modifier.testTag("AllowanceContact-${contact.name}") + ) + HorizontalDivider() + } + } + } + } +} + +@Composable +private fun SetAllowanceContent( + contact: PubkyProfile, + isWorking: Boolean, + onBack: () -> Unit, + onClickSave: (perPaymentUsd: Int, monthlyUsd: Int) -> Unit, + modifier: Modifier = Modifier, +) { + val perPaymentStops = remember { PaykitAllowanceLimits.PER_PAYMENT_STOPS_USD.toImmutableList() } + val monthlyStops = remember { PaykitAllowanceLimits.MONTHLY_STOPS_USD.toImmutableList() } + var perPaymentUsd by rememberSaveable { mutableIntStateOf(perPaymentStops[DEFAULT_PER_PAYMENT_INDEX]) } + var monthlyUsd by rememberSaveable { mutableIntStateOf(monthlyStops[DEFAULT_MONTHLY_INDEX]) } + + AllowanceSheetColumn(modifier = modifier.testTag("SetAllowance")) { + SheetTopBar(titleText = stringResource(R.string.subscriptions__allowance_set_title), onBack = onBack) + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + AllowanceCounterpartyCard(counterparty = contact) + VerticalSpacer(16.dp) + BodyM( + text = stringResource(R.string.subscriptions__allowance_set_explanation) + .replace("{name}", contact.name), + color = Colors.White64, + ) + VerticalSpacer(16.dp) + AllowanceLimitSlider( + title = stringResource(R.string.subscriptions__allowance_payment_limit), + value = perPaymentUsd, + stops = perPaymentStops, + onValueChange = { perPaymentUsd = it }, + testTag = "AllowancePerPayment", + ) + VerticalSpacer(16.dp) + HorizontalDivider() + AllowanceLimitSlider( + title = stringResource(R.string.subscriptions__allowance_monthly_allowance), + value = monthlyUsd, + stops = monthlyStops, + onValueChange = { monthlyUsd = it }, + testTag = "AllowanceMonthly", + ) + VerticalSpacer(16.dp) + HorizontalDivider() + VerticalSpacer(16.dp) + BodyS( + text = stringResource(R.string.subscriptions__allowance_set_summary) + .replace("{perPayment}", AllowanceAmountText.short(perPaymentUsd)) + .replace("{monthly}", AllowanceAmountText.short(monthlyUsd)), + color = Colors.White64, + modifier = Modifier.testTag("AllowanceSummary") + ) + VerticalSpacer(16.dp) + } + PrimaryButton( + text = stringResource(R.string.subscriptions__allowance_save), + onClick = { onClickSave(perPaymentUsd, monthlyUsd) }, + isLoading = isWorking, + modifier = Modifier.testTag("AllowanceSave") + ) + VerticalSpacer(16.dp) + } +} + +@Composable +private fun AllowanceLimitSlider( + title: String, + value: Int, + stops: ImmutableList, + onValueChange: (Int) -> Unit, + testTag: String, +) { + Caption13Up( + text = title, + color = Colors.White64, + modifier = Modifier.padding(vertical = 16.dp) + ) + Slider( + value = value, + steps = stops, + onValueChange = onValueChange, + formatLabel = AllowanceAmountText::short, + activeColor = Colors.Purple, + trackColor = Colors.Purple32, + stopTestTag = { "${testTag}Stop-$it" }, + modifier = Modifier.testTag(testTag) + ) +} + +@Composable +private fun AllowanceReviewContent( + allowance: AllowanceUi, + isWorking: Boolean, + onClickDecline: () -> Unit, + onClickAccept: () -> Unit, + modifier: Modifier = Modifier, +) { + AllowanceSheetColumn(modifier = modifier.testTag("AllowanceReview")) { + SheetTopBar(titleText = stringResource(R.string.subscriptions__allowance_title)) + VerticalSpacer(16.dp) + Display(text = allowance.reviewHeadline.withAccent(accentColor = Colors.Purple)) + VerticalSpacer(16.dp) + AllowanceCounterpartyCard(counterparty = allowance.counterparty, isCard = true) + VerticalSpacer(24.dp) + AllowanceLimitsGrid(allowance = allowance) + VerticalSpacer(24.dp) + BodyM(text = allowance.reviewExplanation, color = Colors.White64) + FillHeight() + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + SecondaryButton( + text = stringResource(R.string.subscriptions__allowance_decline), + onClick = onClickDecline, + enabled = !isWorking, + modifier = Modifier + .weight(1f) + .testTag("AllowanceDecline") + ) + PrimaryButton( + text = stringResource(R.string.subscriptions__allowance_accept), + onClick = onClickAccept, + isLoading = isWorking, + modifier = Modifier + .weight(1f) + .testTag("AllowanceAccept") + ) + } + VerticalSpacer(16.dp) + } +} + +@Composable +private fun AllowanceDetailContent( + allowance: AllowanceUi, + isWorking: Boolean, + onEnd: () -> Unit, + modifier: Modifier = Modifier, +) { + AllowanceSheetColumn(modifier = modifier.testTag("AllowanceDetail")) { + SheetTopBar(titleText = stringResource(R.string.subscriptions__allowance_title)) + AllowanceCounterpartyCard(counterparty = allowance.counterparty, isCard = true) + VerticalSpacer(24.dp) + AllowanceLimitsGrid(allowance = allowance) + VerticalSpacer(24.dp) + Caption13Up(text = stringResource(R.string.subscriptions__allowance_paid_so_far), color = Colors.White64) + VerticalSpacer(8.dp) + BodySSB(text = allowance.paidSoFar, modifier = Modifier.testTag("AllowancePaidSoFar")) + VerticalSpacer(24.dp) + BodyM( + text = allowance.explanation, + color = Colors.White64, + modifier = Modifier.testTag("AllowanceDetailStatus") + ) + FillHeight() + if (allowance.canEnd) { + SwipeToConfirm( + text = stringResource( + if (allowance.isProposal) { + R.string.subscriptions__allowance_swipe_withdraw + } else { + R.string.subscriptions__allowance_swipe_end + } + ), + color = Colors.Purple, + loading = isWorking, + onConfirm = onEnd, + ) + VerticalSpacer(16.dp) + } + } +} + +@Composable +private fun AllowanceUnavailableContent( + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + AllowanceSheetColumn(modifier = modifier) { + SheetTopBar(titleText = stringResource(R.string.subscriptions__allowance_title)) + FillHeight() + BodyM(text = stringResource(R.string.subscriptions__allowance_error_unavailable), color = Colors.White64) + FillHeight() + PrimaryButton(text = stringResource(R.string.common__close), onClick = onClose) + VerticalSpacer(16.dp) + } +} + +@Composable +private fun AllowanceSheetColumn( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = modifier + .fillMaxSize() + .gradientBackground(startColor = Colors.Gray6, endColor = Colors.Black) + .navigationBarsPadding() + .padding(horizontal = 16.dp), + content = content, + ) +} + +@Composable +private fun AllowanceCounterpartyCard( + counterparty: PubkyProfile, + isCard: Boolean = false, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .then( + if (isCard) { + Modifier + .clip(RoundedCornerShape(16.dp)) + .background(Colors.Gray6) + .padding(16.dp) + } else { + Modifier + } + ) + .testTag("AllowanceCounterparty") + ) { + PubkyContactAvatar(profile = counterparty, size = 48.dp) + Column( + modifier = Modifier + .padding(start = 16.dp) + .weight(1f) + ) { + Caption13Up( + text = counterparty.truncatedPublicKey, + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BodyMSB( + text = counterparty.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun AllowanceLimitsGrid(allowance: AllowanceUi) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth()) { + AllowanceLimitCell( + title = stringResource(R.string.subscriptions__allowance_per_payment), + upTo = allowance.perPaymentUpTo, + sats = allowance.perPaymentSats, + testTag = "AllowancePerPaymentValue", + modifier = Modifier.weight(1f) + ) + AllowanceLimitCell( + title = stringResource(R.string.subscriptions__allowance_each_month), + upTo = allowance.monthlyUpTo, + sats = allowance.monthlySats, + testTag = "AllowanceMonthlyValue", + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun AllowanceLimitCell( + title: String, + upTo: String, + sats: Long?, + testTag: String, + modifier: Modifier = Modifier, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier) { + Caption13Up(text = title, color = Colors.White64) + BodySSB(text = upTo, modifier = Modifier.testTag(testTag)) + sats?.let { MoneyCaptionB(sats = it, color = Colors.White64, symbol = true) } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + BottomSheetPreview { + AllowanceContactContent( + contacts = persistentListOf( + previewAllowance(name = "Leo").counterparty, + PubkyProfile.forDisplay( + publicKey = "pubkyqzx4bxnsmp6n1u3y3uyt6hbjmaqwzs5tbaxkh7wwcbzjb8sccq3o", + name = "Mia", + imageUrl = null, + ), + ), + onClickContact = {}, + modifier = Modifier.sheetHeight() + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview2() { + AppThemeSurface { + BottomSheetPreview { + AllowanceContactContent( + contacts = persistentListOf(), + onClickContact = {}, + modifier = Modifier.sheetHeight() + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview3() { + AppThemeSurface { + BottomSheetPreview { + SetAllowanceContent( + contact = previewAllowance(name = "Leo").counterparty, + isWorking = false, + onBack = {}, + onClickSave = { _, _ -> }, + modifier = Modifier.sheetHeight() + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview4() { + AppThemeSurface { + BottomSheetPreview { + AllowanceReviewContent( + allowance = previewAllowance(name = "Ana").copy( + status = PaykitAllowance.Status.AWAITING_MY_ANSWER, + subtitle = "Waiting for your answer", + isAnswerable = true, + isProposal = true, + ), + isWorking = false, + onClickDecline = {}, + onClickAccept = {}, + modifier = Modifier.sheetHeight() + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview5() { + AppThemeSurface { + BottomSheetPreview { + AllowanceDetailContent( + allowance = previewAllowance(), + isWorking = false, + onEnd = {}, + modifier = Modifier.sheetHeight() + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview6() { + AppThemeSurface { + BottomSheetPreview { + AllowanceDetailContent( + allowance = previewAllowance().copy( + status = PaykitAllowance.Status.ENDED, + subtitle = "Ended · $2.00 paid automatically", + explanation = "This allowance has ended. Every request asks again.", + canEnd = false, + ), + isWorking = false, + onEnd = {}, + modifier = Modifier.sheetHeight() + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview7() { + AppThemeSurface { + BottomSheetPreview { + AllowanceUnavailableContent(onClose = {}, modifier = Modifier.sheetHeight()) + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesScreen.kt new file mode 100644 index 0000000000..824b95c1f2 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesScreen.kt @@ -0,0 +1,285 @@ +package to.bitkit.ui.screens.subscriptions + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +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.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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +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.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +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.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.collections.immutable.persistentListOf +import to.bitkit.R +import to.bitkit.models.PubkyProfile +import to.bitkit.models.USD_SYMBOL +import to.bitkit.repositories.PaykitAllowance +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.CaptionB +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.HorizontalSpacer +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.PubkyContactAvatar +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent + +/** Row opacity for an allowance that no longer pays: ended, declined, expired or in conflict. */ +private const val INACTIVE_ROW_ALPHA = 0.64f + +/** The Allowances tab on the Subscriptions screen: the empty state or the allowance list, plus Add Allowance. */ +@Composable +fun AllowancesScreen( + topPadding: Dp, + onClickAdd: () -> Unit, + onClickAllowance: (AllowanceUi) -> Unit, + modifier: Modifier = Modifier, + viewModel: AllowancesViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { viewModel.refresh() } + + AllowancesContent( + uiState = uiState, + topPadding = topPadding, + onClickAdd = onClickAdd, + onClickAllowance = onClickAllowance, + modifier = modifier + ) +} + +@Composable +private fun AllowancesContent( + uiState: AllowancesUiState, + topPadding: Dp, + onClickAdd: () -> Unit, + onClickAllowance: (AllowanceUi) -> Unit, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + var footerHeight by remember { mutableStateOf(0.dp) } + + Box(modifier = modifier.fillMaxSize()) { + when { + !uiState.isLoaded -> Unit + uiState.allowances.isEmpty() -> AllowancesEmptyState( + modifier = Modifier + .fillMaxSize() + .padding(top = topPadding, bottom = footerHeight) + ) + else -> LazyColumn( + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = topPadding + 32.dp, + bottom = footerHeight + 16.dp, + ), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxSize() + ) { + items(uiState.allowances, key = { it.id }) { allowance -> + AllowanceRow( + allowance = allowance, + onClick = { onClickAllowance(allowance) }, + ) + } + } + } + + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .onSizeChanged { footerHeight = with(density) { it.height.toDp() } } + .navigationBarsPadding() + ) { + VerticalSpacer(16.dp) + PrimaryButton( + text = stringResource(R.string.subscriptions__allowance_add), + onClick = onClickAdd, + modifier = Modifier + .padding(horizontal = 16.dp) + .testTag("AllowanceAdd") + ) + VerticalSpacer(16.dp) + } + } +} + +@Composable +private fun AllowancesEmptyState(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 32.dp) + .testTag("AllowancesEmpty") + ) { + FillHeight() + Image( + painter = painterResource(R.drawable.group), + contentDescription = null, + modifier = Modifier + .size(256.dp) + .align(Alignment.CenterHorizontally) + ) + VerticalSpacer(32.dp) + Display( + text = stringResource(R.string.subscriptions__allowances_empty_headline) + .withAccent(accentColor = Colors.Purple), + ) + VerticalSpacer(8.dp) + BodyM(text = stringResource(R.string.subscriptions__allowances_empty_description), color = Colors.White64) + } +} + +@Composable +private fun AllowanceRow( + allowance: AllowanceUi, + onClick: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .alpha(if (allowance.isInactive) INACTIVE_ROW_ALPHA else 1f) + .clip(RoundedCornerShape(16.dp)) + .background(Colors.Gray6) + .clickableAlpha(onClick = onClick) + .padding(16.dp) + .testTag("AllowanceRow-${allowance.id}") + ) { + PubkyContactAvatar(profile = allowance.counterparty, size = 40.dp) + Column( + modifier = Modifier + .padding(start = 16.dp) + .weight(1f) + ) { + BodyMSB( + text = allowance.counterparty.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + CaptionB( + text = allowance.subtitle, + color = Colors.White64, + maxLines = 1, + modifier = Modifier.testTag("AllowanceRowStatus") + ) + } + HorizontalSpacer(8.dp) + Column(horizontalAlignment = Alignment.End) { + AllowanceUsd(amount = allowance.monthlyAmount) + CaptionB( + text = stringResource(R.string.subscriptions__allowance_monthly_limit), + color = Colors.White64, + maxLines = 1, + ) + } + } +} + +/** A dollar amount with a dimmed currency symbol, as the allowance rows show limits. */ +@Composable +internal fun AllowanceUsd(amount: String, modifier: Modifier = Modifier) { + BodyMSB( + text = "$USD_SYMBOL $amount".withAccent(accentColor = Colors.White64), + modifier = modifier + ) +} + +internal fun previewAllowance(id: String = "allowance-1", name: String = "Leo") = AllowanceUi( + id = id, + counterparty = PubkyProfile.forDisplay( + publicKey = "pubky8pinxcsrt5ufsyu3n1pzkq5e3bdi7gbq67pcu7tsnjj65ntx1xy", + name = name, + imageUrl = null, + ), + status = PaykitAllowance.Status.ACTIVE, + subtitle = "Active · $5 a payment", + explanation = "Requests within these limits are paid automatically. Anything above them asks you first.", + reviewHeadline = "Ana offers\nan allowance", + reviewExplanation = "Ana's wallet will pay your requests within these limits without asking each time. " + + "Either of you can end it.", + monthlyAmount = "50.00", + perPaymentUpTo = "Up to $5.00", + monthlyUpTo = "Up to $50.00", + perPaymentSats = 4_512L, + monthlySats = 45_120L, + paidSoFar = "$2.00", + isAnswerable = false, + canEnd = true, + isProposal = false, +) + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + AllowancesContent( + uiState = AllowancesUiState(isLoaded = true), + topPadding = 0.dp, + onClickAdd = {}, + onClickAllowance = {}, + ) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview2() { + AppThemeSurface { + AllowancesContent( + uiState = AllowancesUiState( + isLoaded = true, + allowances = persistentListOf( + previewAllowance(), + previewAllowance(id = "allowance-2", name = "Mia").copy( + status = PaykitAllowance.Status.AWAITING_ANSWER, + subtitle = "Waiting for an answer", + isProposal = true, + ), + previewAllowance(id = "allowance-3", name = "John Carvalho").copy( + status = PaykitAllowance.Status.ENDED, + subtitle = "Ended · $2.00 paid automatically", + canEnd = false, + ), + ), + ), + topPadding = 0.dp, + onClickAdd = {}, + onClickAllowance = {}, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModel.kt new file mode 100644 index 0000000000..067ec47449 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModel.kt @@ -0,0 +1,403 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.ui.screens.subscriptions + +import android.content.Context +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.synonym.paykit.AllowanceLifecycleState +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.data.SettingsStore +import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.models.Toast +import to.bitkit.models.USD +import to.bitkit.models.USD_SYMBOL +import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.PaykitAllowance +import to.bitkit.repositories.PaykitAllowanceEntry +import to.bitkit.repositories.PaykitAllowanceError +import to.bitkit.repositories.PaykitAllowanceLimits +import to.bitkit.repositories.PaykitAllowanceRepo +import to.bitkit.repositories.PaykitPaymentRequestId +import to.bitkit.repositories.PaykitPaymentRequestRepo +import to.bitkit.repositories.PubkyRepo +import to.bitkit.ui.shared.toast.ToastEventBus +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.util.Locale +import javax.inject.Inject +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@Suppress("LongParameterList") +@HiltViewModel +class AllowancesViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val allowanceRepo: PaykitAllowanceRepo, + private val paymentRequestRepo: PaykitPaymentRequestRepo, + private val pubkyRepo: PubkyRepo, + private val currencyRepo: CurrencyRepo, + private val settingsStore: SettingsStore, + private val clock: Clock, +) : ViewModel() { + companion object { + /** How often time-based statuses (not active yet, expired) are re-evaluated while the UI is visible. */ + private val STATUS_REFRESH_INTERVAL = 1.minutes + + /** Keeps the state alive across short configuration changes. */ + private const val STOP_TIMEOUT_MS = 5_000L + } + + private val isWorking = MutableStateFlow(false) + private val authorization = MutableStateFlow(null) + private var authorizedAction: (() -> Unit)? = null + + private val now: Flow = flow { + while (true) { + emit(clock.now()) + delay(STATUS_REFRESH_INTERVAL) + } + } + + private val allowances: Flow> = combine( + allowanceRepo.entries, + allowanceRepo.autoPaidSats, + pubkyRepo.contacts, + currencyRepo.currencyState, + now, + ) { entries, autoPaidSats, contacts, _, now -> + entries.map { it.toUi(contacts, autoPaidSats[it.id] ?: 0uL, now) }.toImmutableList() + } + + private val contactChoices: Flow> = combine( + pubkyRepo.contacts, + paymentRequestRepo.eligibleTargets, + ) { contacts, targets -> + contacts.filter { contact -> + targets.any { PubkyPublicKeyFormat.matches(it.publicKey, contact.publicKey) } + }.toImmutableList() + } + + // A fresh subscriber starts from the unloaded state, so a sheet never renders a stale entry list. + val uiState: StateFlow = combine( + allowances, + contactChoices, + isWorking, + authorization, + ) { allowances, contacts, isWorking, authorization -> + AllowancesUiState( + isLoaded = true, + allowances = allowances, + contacts = contacts, + isWorking = isWorking, + authorization = authorization, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS, replayExpirationMillis = 0), + initialValue = AllowancesUiState(), + ) + + /** Payment request ids paid by an allowance, for the "Auto-paid" suffix in payment history. */ + val autoPaidRequestIds: StateFlow> = combine( + paymentRequestRepo.paymentRequestHistory, + allowanceRepo.autoPaidSats, + ) { history, _ -> + history.map { it.id }.filter(allowanceRepo::isAutoPaid).toImmutableSet() + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), persistentSetOf()) + + fun refresh() { + viewModelScope.launch { allowanceRepo.refresh() } + } + + fun markProposalPresented(entryId: String) { + viewModelScope.launch { allowanceRepo.markProposalPresented(entryId) } + } + + fun propose(contact: PubkyProfile, perPaymentUsd: Int, monthlyUsd: Int, onSuccess: () -> Unit) { + val limits = limitsInSats(perPaymentUsd, monthlyUsd) + if (limits == null) { + viewModelScope.launch { toastError(context.getString(R.string.subscriptions__allowance_error_unavailable)) } + return + } + authorizeThen(isGated = true) { runAction(onSuccess) { allowanceRepo.propose(contact.publicKey, limits) } } + } + + fun accept(entryId: String, onSuccess: () -> Unit) = authorizeThen(isGated = isAllower(entryId)) { + runAction(onSuccess) { allowanceRepo.accept(entryId) } + } + + fun decline(entryId: String, onSuccess: () -> Unit) = runAction(onSuccess) { allowanceRepo.reject(entryId) } + + fun end(entryId: String, onSuccess: () -> Unit) = authorizeThen(isGated = isAllower(entryId)) { + runAction(onSuccess) { allowanceRepo.end(entryId) } + } + + fun onAuthorized() { + val action = authorizedAction ?: return + authorizedAction = null + authorization.update { null } + action() + } + + fun onAuthorizationCancelled() { + authorizedAction = null + authorization.update { null } + } + + private fun isAllower(entryId: String): Boolean = + allowanceRepo.entries.value.firstOrNull { it.id == entryId }?.role == PaykitAllowance.Role.ALLOWER + + /** + * Runs [action] after the same PIN or biometric check as Send when the user requires a PIN for payments. Gated + * actions are the payer's: they create or change the spending power that later pays without asking. + */ + private fun authorizeThen(isGated: Boolean, action: () -> Unit) { + if (!isGated) return action() + if (isWorking.value || authorization.value != null) return + viewModelScope.launch { + val settings = settingsStore.data.first() + if (!settings.isPinEnabled || !settings.isPinForPaymentsEnabled) return@launch action() + authorizedAction = action + authorization.update { AllowanceAuthorization(useBiometrics = settings.isBiometricEnabled) } + } + } + + private fun runAction(onSuccess: () -> Unit, action: suspend () -> Result) { + if (isWorking.value) return + isWorking.update { true } + viewModelScope.launch { + action() + .onSuccess { onSuccess() } + .onFailure { toastError(errorDescription(it)) } + isWorking.update { false } + } + } + + private fun errorDescription(error: Throwable): String = when (error) { + PaykitAllowanceError.ContactNotLinked -> context.getString(R.string.subscriptions__allowance_error_not_linked) + PaykitAllowanceError.Unavailable -> context.getString(R.string.subscriptions__allowance_error_unavailable) + else -> error.message?.takeIf(String::isNotBlank) ?: context.getString(R.string.common__error_body) + } + + private suspend fun toastError(description: String) = ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = description, + ) + + private fun limitsInSats(perPaymentUsd: Int, monthlyUsd: Int): PaykitAllowanceLimits? { + val perPaymentSats = currencyRepo.convertFiatToSats(BigDecimal(perPaymentUsd), USD).getOrNull() ?: return null + val monthlySats = currencyRepo.convertFiatToSats(BigDecimal(monthlyUsd), USD).getOrNull() ?: return null + return PaykitAllowanceLimits( + perPaymentUsd = perPaymentUsd, + monthlyUsd = monthlyUsd, + perPaymentSats = perPaymentSats, + monthlySats = monthlySats, + ) + } + + private fun PaykitAllowanceEntry.toUi(contacts: List, paidSats: ULong, now: Instant): AllowanceUi { + val profile = contacts.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, counterparty) } + ?: PubkyProfile.placeholder(counterparty) + val status = status(now) + val isAllowee = role == PaykitAllowance.Role.ALLOWEE + return AllowanceUi( + id = id, + counterparty = profile, + status = status, + subtitle = subtitle(status, paidSats), + explanation = explanation(status), + reviewHeadline = context.getString( + if (isAllowee) { + R.string.subscriptions__allowance_offer_headline + } else { + R.string.subscriptions__allowance_request_headline + } + ).replace("{name}", profile.name), + reviewExplanation = context.getString( + if (isAllowee) { + R.string.subscriptions__allowance_offer_explanation + } else { + R.string.subscriptions__allowance_request_explanation + } + ).replace("{name}", profile.name), + monthlyAmount = limitAmount(limits?.monthlyUsd, monthlyLimitSats), + perPaymentUpTo = upTo(limits?.perPaymentUsd, perPaymentMaxSats), + monthlyUpTo = upTo(limits?.monthlyUsd, monthlyLimitSats), + perPaymentSats = perPaymentMaxSats?.toDisplaySats(), + monthlySats = monthlyLimitSats?.toDisplaySats(), + paidSoFar = USD_SYMBOL + fiatValue(paidSats), + isAnswerable = primary.isAnswerable, + canEnd = canEnd, + isProposal = primary.lifecycleState == AllowanceLifecycleState.PROPOSED, + ) + } + + private fun PaykitAllowanceEntry.subtitle(status: PaykitAllowance.Status, paidSats: ULong): String = when (status) { + PaykitAllowance.Status.ACTIVE -> listOfNotNull( + context.getString(R.string.subscriptions__allowance_status_active), + perPaymentShort(), + ).joinToString(" · ") + PaykitAllowance.Status.ENDED -> { + val ended = context.getString(R.string.subscriptions__allowance_status_ended) + if (paidSats == 0uL) { + ended + } else { + val paid = context.getString(R.string.subscriptions__allowance_paid_automatically) + .replace("{amount}", USD_SYMBOL + fiatValue(paidSats)) + "$ended · $paid" + } + } + else -> statusText(status) + } + + private fun PaykitAllowanceEntry.explanation(status: PaykitAllowance.Status): String = when (status) { + PaykitAllowance.Status.ACTIVE -> context.getString( + if (role == PaykitAllowance.Role.ALLOWER) { + R.string.subscriptions__allowance_detail_active_allower + } else { + R.string.subscriptions__allowance_detail_active_allowee + } + ) + PaykitAllowance.Status.ENDED -> context.getString(R.string.subscriptions__allowance_detail_ended) + else -> statusText(status) + } + + private fun statusText(status: PaykitAllowance.Status): String = context.getString( + when (status) { + PaykitAllowance.Status.ACTIVE -> R.string.subscriptions__allowance_status_active + PaykitAllowance.Status.AWAITING_ANSWER -> R.string.subscriptions__allowance_status_waiting + PaykitAllowance.Status.AWAITING_MY_ANSWER -> R.string.subscriptions__allowance_status_needs_answer + PaykitAllowance.Status.NOT_YET_ACTIVE -> R.string.subscriptions__allowance_status_scheduled + PaykitAllowance.Status.EXPIRED -> R.string.subscriptions__allowance_status_expired + PaykitAllowance.Status.DECLINED -> R.string.subscriptions__allowance_status_declined + PaykitAllowance.Status.ENDED -> R.string.subscriptions__allowance_status_ended + PaykitAllowance.Status.CONFLICTED -> R.string.subscriptions__allowance_status_conflicted + } + ) + + private fun PaykitAllowanceEntry.perPaymentShort(): String? { + val amount = limits?.perPaymentUsd?.let(AllowanceAmountText::short) + ?: perPaymentMaxSats?.let(::shortLimitValue) + ?: return null + return context.getString(R.string.subscriptions__allowance_per_payment_short).replace("{amount}", amount) + } + + private fun upTo(usd: Int?, sats: ULong?): String { + val amount = usd?.let { USD_SYMBOL + AllowanceAmountText.formatted(BigDecimal(it)) } + ?: sats?.let { USD_SYMBOL + limitValue(it) } + ?: AllowanceAmountText.UNKNOWN + return context.getString(R.string.subscriptions__allowance_up_to).replace("{amount}", amount) + } + + private fun limitAmount(usd: Int?, sats: ULong?): String = + usd?.let { AllowanceAmountText.formatted(BigDecimal(it)) } + ?: sats?.let(::limitValue) + ?: AllowanceAmountText.UNKNOWN + + private fun fiatValue(sats: ULong): String = usdValue(sats)?.let(AllowanceAmountText::formatted) + ?: AllowanceAmountText.UNKNOWN + + private fun limitValue(sats: ULong): String = usdValue(sats)?.let(AllowanceAmountText::limit) + ?: AllowanceAmountText.UNKNOWN + + private fun shortLimitValue(sats: ULong): String = usdValue(sats)?.let(AllowanceAmountText::shortLimit) + ?: (USD_SYMBOL + AllowanceAmountText.UNKNOWN) + + private fun usdValue(sats: ULong): BigDecimal? = + currencyRepo.convertSatsToFiat(sats.toDisplaySats(), USD).getOrNull()?.value +} + +@Stable +data class AllowancesUiState( + val isLoaded: Boolean = false, + val allowances: ImmutableList = persistentListOf(), + val contacts: ImmutableList = persistentListOf(), + val isWorking: Boolean = false, + val authorization: AllowanceAuthorization? = null, +) + +/** A pending PIN or biometric check before a gated allowance action. */ +@Immutable +data class AllowanceAuthorization(val useBiometrics: Boolean) + +/** One allowance entry as the UI shows it, with every amount already converted to US dollars. */ +@Stable +data class AllowanceUi( + val id: String, + val counterparty: PubkyProfile, + val status: PaykitAllowance.Status, + val subtitle: String, + val explanation: String, + val reviewHeadline: String, + val reviewExplanation: String, + val monthlyAmount: String, + val perPaymentUpTo: String, + val monthlyUpTo: String, + val perPaymentSats: Long?, + val monthlySats: Long?, + val paidSoFar: String, + val isAnswerable: Boolean, + val canEnd: Boolean, + val isProposal: Boolean, +) { + val isInactive: Boolean + get() = when (status) { + PaykitAllowance.Status.ENDED, + PaykitAllowance.Status.DECLINED, + PaykitAllowance.Status.EXPIRED, + PaykitAllowance.Status.CONFLICTED, + -> true + else -> false + } +} + +/** US dollar amounts as iOS shows them: grouped, en_US, with limits set in whole dollars rounded back to the dollar. */ +internal object AllowanceAmountText { + /** Shown when an amount cannot be converted, e.g. before exchange rates load. */ + const val UNKNOWN = "—" + + fun formatted(usd: BigDecimal): String = DecimalFormat("#,##0.00", DecimalFormatSymbols(Locale.US)).format(usd) + + fun short(usd: Int): String = USD_SYMBOL + DecimalFormat("#,##0", DecimalFormatSymbols(Locale.US)).format(usd) + + /** + * A limit set in whole dollars on the other wallet, shown back from its BTC terms at today's rate: + * rounded to the dollar so a small rate move does not turn $5 into $4.99. + */ + fun limit(usd: BigDecimal): String = + formatted(if (usd >= BigDecimal.ONE) usd.setScale(0, RoundingMode.HALF_UP) else usd) + + /** [limit] in the payer's short form: whole dollars without cents once it rounds to a dollar or more. */ + fun shortLimit(usd: BigDecimal): String = + if (usd >= BigDecimal.ONE) short(usd.setScale(0, RoundingMode.HALF_UP).toInt()) else USD_SYMBOL + formatted(usd) +} + +private fun ULong.toDisplaySats(): Long = coerceAtMost(Long.MAX_VALUE.toULong()).toLong() 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 14f431e9ea..569227fc8c 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 @@ -49,6 +49,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.airbnb.lottie.compose.LottieAnimation import com.airbnb.lottie.compose.LottieCompositionSpec @@ -81,6 +82,7 @@ import to.bitkit.repositories.canceledPaidThrough import to.bitkit.repositories.paykitRate import to.bitkit.repositories.runsUntilPaidThrough import to.bitkit.ui.LocalCurrencies +import to.bitkit.ui.components.AllowanceRoute import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.BodyS @@ -131,6 +133,7 @@ fun SubscriptionsScreen( onDetails: (PaykitSubscriptionId) -> Unit, onPaymentRequestDetails: (PaykitPaymentRequestId) -> Unit, showPayments: Boolean = false, + allowancesViewModel: AllowancesViewModel = hiltViewModel(), ) { val subscriptions by appViewModel.subscriptions.collectAsStateWithLifecycle() val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() @@ -154,6 +157,7 @@ fun SubscriptionsScreen( }, onCreateSubscription = onCreateSubscription, paymentsContent = { topPadding -> + val autoPaidRequestIds by allowancesViewModel.autoPaidRequestIds.collectAsStateWithLifecycle() PaymentRequestsScreen( appViewModel = appViewModel, onBack = onBack, @@ -161,6 +165,18 @@ fun SubscriptionsScreen( onDetails = onPaymentRequestDetails, showsNavigationBar = false, topPadding = topPadding, + autoPaidRequestIds = autoPaidRequestIds, + ) + }, + allowancesContent = { topPadding -> + AllowancesScreen( + topPadding = topPadding, + onClickAdd = { appViewModel.showSheet(Sheet.Allowance(AllowanceRoute.Set)) }, + onClickAllowance = { + val route = if (it.isAnswerable) AllowanceRoute.Review(it.id) else AllowanceRoute.Details(it.id) + appViewModel.showSheet(Sheet.Allowance(route)) + }, + viewModel = allowancesViewModel, ) }, ) @@ -178,6 +194,7 @@ internal fun SubscriptionsContent( onSubscription: (PaykitSubscription) -> Unit, onCreateSubscription: () -> Unit, paymentsContent: @Composable (topPadding: Dp) -> Unit, + allowancesContent: @Composable (topPadding: Dp) -> Unit = {}, ) { val sections = subscriptionSections(subscriptions, acceptedAt, now) val hasVisibleSubscriptions = with(sections) { @@ -204,6 +221,8 @@ internal fun SubscriptionsContent( ) { if (selectedTab == SubscriptionTab.Payments) { paymentsContent(headerHeight) + } else if (selectedTab == SubscriptionTab.Allowances) { + allowancesContent(headerHeight) } else if (!hasVisibleSubscriptions) { SubscriptionEmptyState( Modifier @@ -351,7 +370,7 @@ private fun SubscriptionTabs( onTabChange: (SubscriptionTab) -> Unit, ) { CustomTabRowWithSpacing( - tabs = persistentListOf(SubscriptionTab.Overview, SubscriptionTab.Payments), + tabs = persistentListOf(SubscriptionTab.Overview, SubscriptionTab.Allowances, SubscriptionTab.Payments), currentTabIndex = selectedTab.ordinal, selectedColor = Colors.White, onTabChange = onTabChange, @@ -362,12 +381,14 @@ private fun SubscriptionTabs( internal enum class SubscriptionTab : TabItem { Overview, + Allowances, Payments; override val uiText: String @Composable get() = stringResource( when (this) { Overview -> R.string.subscriptions__overview + Allowances -> R.string.subscriptions__allowances Payments -> R.string.subscriptions__payments } ) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 9d19e35581..60e4660ce7 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.core.app.NotificationManagerCompat import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -178,6 +179,9 @@ import to.bitkit.repositories.OnchainSendBlockedError import to.bitkit.repositories.OnchainSendNotDispatchedError import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.OnchainSendPendingError +import to.bitkit.repositories.PaykitAllowanceError +import to.bitkit.repositories.PaykitAllowanceEvent +import to.bitkit.repositories.PaykitAllowanceRepo import to.bitkit.repositories.PaykitOnchainPaymentProofResolution import to.bitkit.repositories.PaykitPaymentProofKind import to.bitkit.repositories.PaykitPaymentProofRepo @@ -224,10 +228,13 @@ import to.bitkit.services.MigrationService import to.bitkit.services.NodeServiceFgState import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.services.PubkyService +import to.bitkit.ui.ID_NOTIFICATION_SKIPPED import to.bitkit.ui.Routes +import to.bitkit.ui.components.AllowanceRoute import to.bitkit.ui.components.Sheet import to.bitkit.ui.components.SubscriptionRoute import to.bitkit.ui.components.TimedSheetType +import to.bitkit.ui.pushNotification import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.ui.shared.toast.ToastQueueManager import to.bitkit.ui.sheets.SendRoute @@ -297,6 +304,7 @@ class AppViewModel @Inject constructor( private val privatePaykitRepo: PrivatePaykitRepo, private val contactPaymentSettingsRepo: ContactPaymentSettingsRepo, private val paykitPaymentRequestRepo: PaykitPaymentRequestRepo, + private val paykitAllowanceRepo: PaykitAllowanceRepo, private val paykitPaymentProofRepo: PaykitPaymentProofRepo, private val paykitPaymentRequestDiagnostics: PaykitPaymentRequestDiagnostics, private val refreshContactPaykitLink: RefreshContactPaykitLinkUseCase, @@ -751,6 +759,7 @@ class AppViewModel @Inject constructor( observePaykitPaymentRequestConnectivity() observeIncomingPaykitPaymentRequests() observePaykitOnchainPaymentResolution() + observePaykitAllowanceEvents() observeSendEvents() viewModelScope.launch { checkCriticalAppUpdate() @@ -923,6 +932,7 @@ class AppViewModel @Inject constructor( preserveRequestedPaymentRequest = paymentRequestIdentity == null, ) paymentRequestIdentity = null + paykitAllowanceRepo.deactivate() try { paykitPaymentRequestRepo.clear() } finally { @@ -946,6 +956,7 @@ class AppViewModel @Inject constructor( isPaymentRequestIdentityActivating = true val requestRefresh = try { paykitPaymentRequestRepo.activate(state.publicKey) + paykitAllowanceRepo.activate(state.publicKey) if (!pubkyRepo.isCurrent(signIn)) return paymentRequestIdentity = state.publicKey refreshPrivateOnlyPaykitApp("contact sync", onlyIfNeeded = !identityChanged) @@ -1158,19 +1169,98 @@ class AppViewModel @Inject constructor( ) { return Result.failure(PaykitPaymentRequestError.RequestUnavailable) } - val result = if (forceFresh) { - paykitPaymentRequestRepo.refreshAfterStateChange(mode) - } else { - paykitPaymentRequestRepo.refresh(mode, messagePriority) + // New requests and an allowance's acceptance can arrive in one intake: requests an allowance may cover are + // held from presentation until the allowances are refreshed too. + val releaseCoverage = paykitAllowanceRepo.beginCoverageUpdate() + val result = try { + val refreshed = if (forceFresh) { + paykitPaymentRequestRepo.refreshAfterStateChange(mode) + } else { + paykitPaymentRequestRepo.refresh(mode, messagePriority) + } + refreshed.onSuccess { + activityRepo.backfillPaykitContacts() + clearHandledSubscriptionNotification() + if (mode == PaykitPaymentRequestRefreshMode.FULL) reconcilePaykitUsdt() + paykitAllowanceRepo.refresh() + } + } finally { + releaseCoverage() } return result.onSuccess { - activityRepo.backfillPaykitContacts() - clearHandledSubscriptionNotification() - if (mode == PaykitPaymentRequestRefreshMode.FULL) reconcilePaykitUsdt() + if (processAllowanceRequests()) paykitPaymentRequestRepo.refresh() presentNextIncomingPaykitPaymentRequest() } } + /** Automatic payments hold background Paykit work back like a manual payment submission does. */ + private suspend fun processAllowanceRequests(): Boolean { + val finishSubmission = beginPaymentSubmission() + return try { + // A request whose manual sheet is open or being prepared is the user's to pay, never the allowance's. + val manualIds = setOfNotNull( + paymentRequestPreparation.value?.request?.id, + activeContactPaymentContext?.incomingPaymentRequest?.id, + ) + paykitAllowanceRepo.processIncomingRequests( + paykitPaymentRequestRepo.pendingRequests.value.filterNot { it.id in manualIds }, + ) + } finally { + finishSubmission() + } + } + + private fun observePaykitAllowanceEvents() { + viewModelScope.launch { + paykitAllowanceRepo.events.collect(::handlePaykitAllowanceEvent) + } + } + + private fun handlePaykitAllowanceEvent(event: PaykitAllowanceEvent) { + when (event) { + is PaykitAllowanceEvent.PaidAutomatically -> notifyAllowanceEvent( + type = Toast.ToastType.LIGHTNING, + title = context.getString(R.string.subscriptions__allowance_executed_title), + description = context.getString(R.string.subscriptions__allowance_executed_description) + .replace("{amount}", allowanceFiatAmount(event.amountSats)) + .replace("{name}", allowanceContactName(event.counterparty)), + testTag = "AllowancePaidToast", + ) + + is PaykitAllowanceEvent.LimitReached -> notifyAllowanceEvent( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.subscriptions__allowance_limit_title), + description = context.getString(R.string.subscriptions__allowance_limit_description) + .replace("{amount}", allowanceFiatAmount(event.amountSats)) + .replace("{name}", allowanceContactName(event.counterparty)), + testTag = "AllowanceLimitToast", + ) + + PaykitAllowanceEvent.LedgerChanged -> Unit + } + } + + /** + * Allowance events post a system notification when allowed, as on iOS: the request sheet that opens right after a + * limit is reached would cover an in-app toast. When notifications are off they fall back to a toast. + */ + private fun notifyAllowanceEvent(type: Toast.ToastType, title: String, description: String, testTag: String) { + val isPosted = NotificationManagerCompat.from(context).areNotificationsEnabled() && + context.pushNotification(title, description) != ID_NOTIFICATION_SKIPPED + if (isPosted) return + toast(type = type, title = title, description = description, testTag = testTag) + } + + private fun allowanceFiatAmount(sats: ULong): String = + currencyRepo.convertSatsToFiat(sats.toLong()).getOrNull()?.let { "${it.symbol}${it.formatted}" } + ?: "$sats sats" + + private fun allowanceContactName(publicKey: String): String = + pubkyRepo.contacts.value.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, publicKey) } + ?.name + ?.takeIf { it.isNotBlank() } + ?: PubkyPublicKeyFormat.display(publicKey) + private suspend fun clearHandledSubscriptionNotification() { val requestId = requestedPaymentRequestId.value ?: return val identity = requestedPaymentRequestIdentity ?: return @@ -1452,19 +1542,29 @@ class AppViewModel @Inject constructor( val preparation = paymentRequestPreparation.value if (isPresentingPaymentRequest || isPaymentRequestPresentationBlocked(preparation)) return if (preparation == null && requestedPaymentRequestId.value == null) { + paykitAllowanceRepo.proposalForPresentation()?.let { + showSheet(Sheet.Allowance(AllowanceRoute.Review(it.id))) + return + } paykitPaymentRequestRepo.automaticSubscriptionProposals().firstOrNull()?.let { showSheet(Sheet.Subscription(SubscriptionRoute.Review(it.id))) return } } - val requests = paymentRequestsForPresentation(preparation) ?: return + val candidates = paymentRequestsForPresentation(preparation) ?: return + if (finishRequestedAutomaticPayment(candidates)) return + val requests = candidates + .filterNot { paykitAllowanceRepo.isAutomaticallyHandling(it) || isAllowancePending(it) } + .takeIf { it.isNotEmpty() } + ?: return val generation = paymentRequestPresentationGeneration isPresentingPaymentRequest = true activePaymentRequestPresentationGeneration = generation var stopped = false try { for (request in requests) { - if (presentIncomingPaymentRequestOrStop(request, generation)) { + // Checked right before the sheet shows: an allowance refresh may have started since the filter ran. + if (!isAllowancePending(request) && presentIncomingPaymentRequestOrStop(request, generation)) { stopped = true break } @@ -1477,12 +1577,38 @@ class AppViewModel @Inject constructor( if (isPaymentRequestPresentationBlocked()) return if ( stopped && generation != paymentRequestPresentationGeneration || - paymentRequestsForPresentation()?.any { next -> requests.none { it.id == next.id } } == true + paymentRequestsForPresentation() + ?.filterNot { paykitAllowanceRepo.isAutomaticallyHandling(it) || isAllowancePending(it) } + ?.any { next -> requests.none { it.id == next.id } } == true ) { presentNextIncomingPaykitPaymentRequest() } } + /** A request an allowance may cover is held while the allowance state is updating, so it is never paid twice. */ + private fun isAllowancePending(request: PaykitPaymentRequest): Boolean = + paykitAllowanceRepo.isUpdating && paykitAllowanceRepo.mayCover(request) + + /** A manual retry of a failed automatic invoice reuses its hash; a pending manual payment owns it then. */ + private fun isAutomaticAllowancePayment(paymentHash: String): Boolean = + !pendingPaymentRepo.isPending(paymentHash) && paykitAllowanceRepo.isAutomaticPayment(paymentHash) + + /** A request the user opened while an allowance pays it is released, so it never holds other requests back. */ + private suspend fun finishRequestedAutomaticPayment(candidates: List): Boolean { + val requestedId = requestedPaymentRequestId.value ?: return false + val request = candidates.singleOrNull { it.id == requestedId } ?: return false + if (!paykitAllowanceRepo.isAutomaticallyHandling(request)) return false + val restorePaymentRequestSheet = shouldRestorePaymentRequestSheet + clearRequestedPaymentRequest() + toast( + type = Toast.ToastType.INFO, + title = context.getString(R.string.subscriptions__allowance_payment_in_progress), + testTag = "AllowancePaymentInProgressToast", + ) + if (restorePaymentRequestSheet) showSheet(Sheet.PaymentRequests) + return true + } + private fun paymentRequestsForPresentation(preparation: PaymentRequestPreparation?): List? = paymentRequestsForPresentation() ?.filter { preparation == null || it.id == preparation.request.id } @@ -2477,6 +2603,8 @@ class AppViewModel @Inject constructor( refreshIncomingPaykitPaymentRequests() } refreshPaymentActivity(paymentHash) + // A failed automatic Allowance payment falls back to the request's manual flow without a send error. + if (isAutomaticAllowancePayment(paymentHash)) return if (pendingPaymentRepo.isPending(paymentHash)) { clearPendingContactPaymentContext(paymentHash) pendingPaymentRepo.resolve(PendingPaymentResolution.Failure(paymentHash, event.reason)) @@ -2572,6 +2700,8 @@ class AppViewModel @Inject constructor( feePaidMsat = event.feePaidMsat, ).wasQuickPay refreshPaymentActivity(paymentHash) + // An automatic Allowance payment settles headlessly; the allowance repo posts its notice and contact tag. + if (isAutomaticAllowancePayment(paymentHash)) return if (!pendingPaymentRepo.isPending(paymentHash)) { notifyPaymentSentOnLightning(event) return @@ -5563,12 +5693,23 @@ class AppViewModel @Inject constructor( } } + val allowanceAttemptId = beginManualAllowancePayment( + preparedPaymentProofRequest, + contactPaymentContext, + ).getOrElse { + releasePrivatePaymentListIfNeeded(contactPaymentContext) + cancelPaymentProofPreparation(preparedPaymentProofRequest) + handlePaymentPreparationFailure(it, contactPaymentContext) + return + } + when (_sendUiState.value.payMethod) { SendMethod.ONCHAIN -> proceedWithOnchainPayment( incomingPaymentRequest, preparedPaymentProofRequest, contactPaymentContext, amount, + allowanceAttemptId, ) SendMethod.LIGHTNING -> proceedWithLightningPayment( @@ -5576,20 +5717,46 @@ class AppViewModel @Inject constructor( preparedPaymentProofRequest, contactPaymentContext, amount, + allowanceAttemptId, ) } } + /** + * Reports a manual payment of an incoming request to the allowance ledger, so an allowance never pays the same + * request again. Only a payment the ledger already holds stops this one; any other ledger failure is logged. + */ + private suspend fun beginManualAllowancePayment( + request: PaykitPaymentRequest?, + contactPaymentContext: ContactPaymentContext?, + ): Result { + if (request == null) return Result.success(null) + val endpointIdentifier = paymentProofPreparation(contactPaymentContext).endpointIdentifier + return paykitAllowanceRepo.beginManualPayment(request, endpointIdentifier) + .recoverCatching { + if (it is PaykitAllowanceError.PaymentAlreadyRecorded) throw it + Logger.warn("Failed to report a manual payment to the allowance ledger", it, context = TAG) + null + } + } + + private fun finishManualAllowancePayment(attemptId: String?, succeeded: Boolean?, transactionId: String? = null) { + if (attemptId == null) return + viewModelScope.launch { paykitAllowanceRepo.finishManualPayment(attemptId, succeeded, transactionId) } + } + private suspend fun proceedWithOnchainPayment( incomingPaymentRequest: PaykitPaymentRequest?, preparedPaymentProofRequest: PaykitPaymentRequest?, contactPaymentContext: ContactPaymentContext?, amount: ULong, + allowanceAttemptId: String?, ) { val originalPayer = preparedPaymentProofRequest?.let { paykitPaymentProofRepo.captureOriginalOnchainPayer(it).getOrElse { error -> releasePrivatePaymentListIfNeeded(contactPaymentContext) cancelPaymentProofPreparation(preparedPaymentProofRequest) + finishManualAllowancePayment(allowanceAttemptId, succeeded = false) handlePaymentPreparationFailure(error, contactPaymentContext) return } @@ -5635,6 +5802,7 @@ class AppViewModel @Inject constructor( proofPreparation, outcome ) + finishManualAllowancePayment(allowanceAttemptId, succeeded = true, transactionId = outcome.txid) onSendSuccess( NewTransactionSheetDetails( type = NewTransactionSheetType.ONCHAIN, @@ -5655,6 +5823,7 @@ class AppViewModel @Inject constructor( } is OnchainSendOutcome.Rejected -> { + finishManualAllowancePayment(allowanceAttemptId, succeeded = null) toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.wallet__error_sending_title), @@ -5668,7 +5837,10 @@ class AppViewModel @Inject constructor( ) } - is OnchainSendOutcome.Unknown -> showUnresolvedOnchainSend(outcome.txid, amount, incomingPaymentRequest) + is OnchainSendOutcome.Unknown -> { + finishManualAllowancePayment(allowanceAttemptId, succeeded = null) + showUnresolvedOnchainSend(outcome.txid, amount, incomingPaymentRequest) + } } }.onFailure { error -> handleOnchainPaymentFailure( @@ -5678,6 +5850,7 @@ class AppViewModel @Inject constructor( incomingPaymentRequest = incomingPaymentRequest, preparedPaymentProofRequest = proofRequest, contactPaymentContext = contactPaymentContext, + allowanceAttemptId = allowanceAttemptId, ) } } @@ -5689,6 +5862,7 @@ class AppViewModel @Inject constructor( incomingPaymentRequest: PaykitPaymentRequest?, preparedPaymentProofRequest: PaykitPaymentRequest?, contactPaymentContext: ContactPaymentContext?, + allowanceAttemptId: String? = null, ) { val amount = _sendUiState.value.amount val unresolved = error is OnchainSendPendingError || error is OnchainSendBlockedError || @@ -5696,6 +5870,7 @@ class AppViewModel @Inject constructor( (sendAttempted && error !is OnchainSendNotDispatchedError) if (unresolved) { Logger.warn("On-chain payment outcome is uncertain after send started", error, context = TAG) + finishManualAllowancePayment(allowanceAttemptId, succeeded = null) if (!sendAttempted) releasePrivatePaymentListIfNeeded(contactPaymentContext) if (!paymentProofStarted) cancelPaymentProofPreparation(preparedPaymentProofRequest) val previous = (error as? OnchainSendBlockedError)?.attempt @@ -5774,6 +5949,7 @@ class AppViewModel @Inject constructor( if (paymentProofStarted) { incomingPaymentRequest?.let { paykitPaymentProofRepo.failOnchainPayment(it) } } + finishManualAllowancePayment(allowanceAttemptId, succeeded = false) releasePrivatePaymentListIfNeeded(contactPaymentContext) cancelPaymentProofPreparation(preparedPaymentProofRequest) if (error is OnchainSendNotDispatchedError) paykitPaymentProofRepo.reconcile() @@ -5918,6 +6094,7 @@ class AppViewModel @Inject constructor( preparedPaymentProofRequest: PaykitPaymentRequest?, contactPaymentContext: ContactPaymentContext?, amount: ULong, + allowanceAttemptId: String?, ) { val decodedInvoice = requireNotNull(_sendUiState.value.decodedInvoice) val paymentAmount = if (decodedInvoice.amountSatoshis > 0uL) null else amount @@ -5928,6 +6105,7 @@ class AppViewModel @Inject constructor( associateLightningPaymentProof(incomingPaymentRequest, paymentHash).onFailure { releasePrivatePaymentListIfNeeded(contactPaymentContext) cancelPaymentProofPreparation(proofRequest) + finishManualAllowancePayment(allowanceAttemptId, succeeded = false) handlePaymentPreparationFailure(it, contactPaymentContext) return } @@ -5946,6 +6124,8 @@ class AppViewModel @Inject constructor( } val lnurlComment = savePendingLnurlComment(decodedInvoice, paymentHash) + // The node's payment events settle this attempt by hash, like an automatic one. + allowanceAttemptId?.let { paykitAllowanceRepo.manualLightningPaymentSent(it, paymentHash) } var authorizationError: Throwable? = null val result = sendLightning( @@ -5962,6 +6142,7 @@ class AppViewModel @Inject constructor( paykitPaymentProofRepo.failLightningPayment(paymentHash) releasePrivatePaymentListIfNeeded(contactPaymentContext) cancelPaymentProofPreparation(proofRequest) + finishManualAllowancePayment(allowanceAttemptId, succeeded = false) createdMetadataPaymentId?.let { preActivityMetadataRepo.deletePreActivityMetadata(it) } lnurlComment?.let { activityRepo.clearPendingLightningMessage(paymentHash) } handlePaymentPreparationFailure(it, contactPaymentContext) @@ -5993,6 +6174,7 @@ class AppViewModel @Inject constructor( } releasePrivatePaymentListIfNeeded(contactPaymentContext) cancelPaymentProofPreparation(proofRequest) + finishManualAllowancePayment(allowanceAttemptId, succeeded = false) createdMetadataPaymentId?.let { preActivityMetadataRepo.deletePreActivityMetadata(it) } lnurlComment?.let { activityRepo.clearPendingLightningMessage(paymentHash) } Logger.error("Error sending lightning payment", error, context = TAG) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 6d30699ed9..597ab37d93 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1457,6 +1457,53 @@ تعذر الدفع لجهة الاتصال في انتظار تحديث جهة الاتصال لتفاصيل الدفع الخاصة بها. يرجى المحاولة مرة أخرى قريبًا. نشط + قبول + إضافة ميزانية + مدفوع تلقائيًا + اختر جهة اتصال + رفض + يتم دفع طلباتك ضمن هذه الحدود تلقائيًا. + يتم دفع الطلبات ضمن هذه الحدود تلقائيًا. أي طلب يتجاوزها سيطلب موافقتك أولًا. + انتهت هذه الميزانية. سيطلب كل طلب موافقتك مجددًا. + كل شهر + جهة الاتصال هذه غير متصلة بعد. حاول مرة أخرى بعد قليل. + هذه الميزانية غير متاحة حاليًا. + أرسل الدفع التلقائي {amount} إلى {name} + تم تنفيذ الدفعة + طلب بقيمة {amount} من {name} يتجاوز ميزانيتك. راجعه للدفع. + تم بلوغ الحد + الميزانية الشهرية + الحد الشهري + أضف جهة اتصال أولًا. تغطي الميزانيات طلبات الدفع من جهات اتصالك. + ستدفع محفظة {name} طلباتك ضمن هذه الحدود دون السؤال في كل مرة. يمكن لأي منكما إنهاؤها. + {name} يعرض عليك\n<accent>ميزانية</accent> + تم دفع {amount} تلقائيًا + مدفوع تلقائيًا + يتم دفع هذا الطلب تلقائيًا بالفعل. + حد الدفعة + لكل دفعة + {amount} لكل دفعة + ستدفع محفظتك طلبات {name} ضمن هذه الحدود دون سؤالك في كل مرة. يمكن لأي منكما إنهاؤها. + {name} يطلب\n<accent>ميزانية</accent> + حفظ الميزانية + الموافقة تلقائيًا على طلبات الدفع من {name} ضمن هذه الحدود: + سيتم دفع الطلبات حتى {perPayment} تلقائيًا، وحتى {monthly} شهريًا، دون سؤالك في كل مرة. + تعيين الميزانية + نشطة + تحتاج إلى انتباه + مرفوضة + منتهية + منتهية الصلاحية + بانتظار ردك + غير نشطة بعد + بانتظار الرد + اسحب لإنهاء الميزانية + اسحب للتراجع + الميزانية + حتى {amount} + الميزانيات + يمكنك تعيين حدود للإنفاق لتلبية طلبات الدفع من جهات الاتصال الموثوقة تلقائيًا. + أعدّ\n<accent>الميزانيات</accent> إلغاء الاشتراك اختر المستلم اختصر اسم الاشتراك أو وصفه وحاول مرة أخرى. diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index be92ca0479..0d7c6b6457 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1472,6 +1472,53 @@ No hay ningún endpoint de pago compatible disponible. Esperando a que el contacto actualice sus datos de pago. Vuelva a intentarlo en breve. Activa + Aceptar + Añadir asignación + Pagado automáticamente + Elegir contacto + Rechazar + Sus solicitudes dentro de estos límites se pagan automáticamente. + Las solicitudes dentro de estos límites se pagan automáticamente. Para cualquier monto superior, se le preguntará primero. + Esta asignación finalizó. Cada solicitud volverá a pedir su aprobación. + Cada mes + Este contacto aún no está conectado. Vuelva a intentarlo en un momento. + Esta asignación no está disponible en este momento. + El pago automático envió {amount} a {name} + Pago ejecutado + Una solicitud de {amount} de {name} supera su asignación. Revísela para pagarla. + Límite alcanzado + Asignación mensual + Límite mensual + Primero añada un contacto. Las asignaciones cubren las solicitudes de pago de sus contactos. + La billetera de {name} pagará sus solicitudes dentro de estos límites sin preguntar cada vez. Cualquiera de los dos puede finalizarla. + {name} le ofrece\n<accent>una asignación</accent> + {amount} pagado automáticamente + Pagado automáticamente + Esta solicitud ya se está pagando automáticamente. + Límite de pago + Por pago + {amount} por pago + Su billetera pagará las solicitudes de {name} dentro de estos límites sin preguntarle cada vez. Cualquiera de los dos puede finalizarla. + {name} solicita\n<accent>una asignación</accent> + Guardar asignación + Aprobar automáticamente las solicitudes de pago de {name} por debajo de estos límites: + Las solicitudes de hasta {perPayment} se pagarán automáticamente, hasta {monthly} al mes, sin preguntarle cada vez. + Establecer asignación + Activa + Requiere atención + Rechazada + Finalizada + Vencida + Esperando su respuesta + Aún no activa + Esperando una respuesta + Deslice para finalizar la asignación + Deslice para retirar + Asignación + Hasta {amount} + Asignaciones + Puede establecer límites de gasto para pagar automáticamente las solicitudes de pago de contactos de confianza. + Configure\n<accent>asignaciones</accent> Cancelar suscripción Elegir destinatario Acorte el nombre o la descripción de la suscripción y vuelva a intentarlo. diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index d454c6b474..77d5335ca7 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1469,6 +1469,53 @@ No hi ha cap punt de pagament compatible disponible. Esperant que el contacte actualitzi les seves dades de pagament. Torna-ho a provar d\'aquí a poc. Activa + Acceptar + Afegir assignació + Pagat automàticament + Triar contacte + Rebutjar + Les teves sol·licituds dins d\'aquests límits es paguen automàticament. + Les sol·licituds dins d\'aquests límits es paguen automàticament. Per a qualsevol import superior, primer se\'t preguntarà. + Aquesta assignació ha finalitzat. Cada sol·licitud tornarà a demanar la teva aprovació. + Cada mes + Aquest contacte encara no està connectat. Torna-ho a provar d\'aquí a un moment. + Aquesta assignació no està disponible ara mateix. + El pagament automàtic ha enviat {amount} a {name} + Pagament executat + Una sol·licitud de {amount} de {name} supera la teva assignació. Revisa-la per pagar-la. + Límit assolit + Assignació mensual + Límit mensual + Primer afegeix un contacte. Les assignacions cobreixen les sol·licituds de pagament dels teus contactes. + La cartera de {name} pagarà les teves sol·licituds dins d\'aquests límits sense preguntar cada vegada. Qualsevol de vosaltres hi pot posar fi. + {name} t\'ofereix\n<accent>una assignació</accent> + {amount} pagat automàticament + Pagat automàticament + Aquesta sol·licitud ja s\'està pagant automàticament. + Límit de pagament + Per pagament + {amount} per pagament + La teva cartera pagarà les sol·licituds de {name} dins d\'aquests límits sense preguntar-te cada vegada. Qualsevol de vosaltres hi pot posar fi. + {name} demana\n<accent>una assignació</accent> + Desar assignació + Aprova automàticament les sol·licituds de pagament de {name} per sota d\'aquests límits: + Les sol·licituds de fins a {perPayment} es pagaran automàticament, fins a {monthly} al mes, sense preguntar-te cada vegada. + Establir assignació + Activa + Requereix atenció + Rebutjada + Finalitzada + Caducada + Esperant la teva resposta + Encara no activa + Esperant una resposta + Desplaça per finalitzar l\'assignació + Desplaça per retirar + Assignació + Fins a {amount} + Assignacions + Pots establir límits de despesa per pagar automàticament les sol·licituds de pagament de contactes de confiança. + Configura\n<accent>assignacions</accent> Cancel·lar subscripció Triar destinatari Escurça el nom o la descripció de la subscripció i torna-ho a provar. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 12163a1f2d..7729d92c97 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1471,6 +1471,53 @@ Není k dispozici žádný kompatibilní platební endpoint. Čeká se, až kontakt aktualizuje své platební údaje. Zkuste to prosím za chvíli znovu. Aktivní + Přijmout + Přidat rozpočet + Zaplaceno automaticky + Vybrat kontakt + Odmítnout + Vaše žádosti v rámci těchto limitů se platí automaticky. + Žádosti v rámci těchto limitů se platí automaticky. U všeho nad nimi se vás nejprve zeptáme. + Tento rozpočet skončil. Každá žádost bude opět vyžadovat váš souhlas. + Každý měsíc + Tento kontakt ještě není připojen. Zkuste to za chvíli znovu. + Tento rozpočet teď není k dispozici. + Automatická platba odeslala {amount} uživateli {name} + Platba provedena + Žádost o {amount} od {name} překračuje váš rozpočet. Zkontrolujte ji a zaplaťte. + Limit dosažen + Měsíční rozpočet + Měsíční limit + Nejprve přidejte kontakt. Rozpočty se vztahují na žádosti o platbu od vašich kontaktů. + Peněženka uživatele {name} bude platit vaše žádosti v rámci těchto limitů, aniž by se pokaždé ptala. Kterákoli strana jej může ukončit. + {name} nabízí\n<accent>rozpočet</accent> + {amount} zaplaceno automaticky + Zaplaceno automaticky + Tato žádost se již platí automaticky. + Limit platby + Na platbu + {amount} za platbu + Vaše peněženka bude platit žádosti uživatele {name} v rámci těchto limitů, aniž by se vás pokaždé ptala. Kterákoli strana jej může ukončit. + {name} žádá o\n<accent>rozpočet</accent> + Uložit rozpočet + Automaticky schvalovat žádosti o platbu od {name} pod těmito limity: + Žádosti do {perPayment} budou zaplaceny automaticky, až do {monthly} měsíčně, aniž bychom se vás pokaždé ptali. + Nastavit rozpočet + Aktivní + Vyžaduje pozornost + Odmítnuto + Ukončeno + Vypršelo + Čeká na vaši odpověď + Zatím neaktivní + Čeká na odpověď + Přejeďte pro ukončení rozpočtu + Přejeďte pro stažení + Rozpočet + Až {amount} + Rozpočty + Můžete nastavit limity útraty pro automatické vyřizování žádostí o platbu od důvěryhodných kontaktů. + Nastavte si\n<accent>rozpočty</accent> Zrušit předplatné Vybrat příjemce Zkraťte název nebo popis předplatného a zkuste to znovu. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 6ba9bcbeff..1ce7c4c9b2 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1470,6 +1470,53 @@ Warte darauf, dass der Kontakt seine Zahlungsdaten aktualisiert. Bitte versuche es in Kürze erneut. Akzeptierte Zahlungen Aktiv + Annehmen + Budget hinzufügen + Automatisch bezahlt + Kontakt wählen + Ablehnen + Deine Anforderungen innerhalb dieser Limits werden automatisch bezahlt. + Anforderungen innerhalb dieser Limits werden automatisch bezahlt. Bei allem darüber wirst du zuerst gefragt. + Dieses Budget ist beendet. Bei jeder Anforderung wirst du wieder gefragt. + Jeden Monat + Dieser Kontakt ist noch nicht verbunden. Versuche es gleich noch einmal. + Dieses Budget ist gerade nicht verfügbar. + Automatische Zahlung von {amount} an {name} gesendet + Zahlung ausgeführt + Eine Anforderung über {amount} von {name} übersteigt dein Budget. Prüfe sie, um zu bezahlen. + Limit erreicht + Monatliches Budget + Monatslimit + Füge zuerst einen Kontakt hinzu. Budgets gelten für Zahlungsanforderungen deiner Kontakte. + Die Wallet von {name} bezahlt deine Anforderungen innerhalb dieser Limits, ohne jedes Mal nachzufragen. Ihr könnt es beide beenden. + {name} bietet dir\n<accent>ein Budget</accent> + {amount} automatisch bezahlt + Automatisch bezahlt + Diese Anforderung wird bereits automatisch bezahlt. + Zahlungslimit + Pro Zahlung + {amount} pro Zahlung + Deine Wallet bezahlt die Anforderungen von {name} innerhalb dieser Limits, ohne dich jedes Mal zu fragen. Ihr könnt es beide beenden. + {name} bittet um\n<accent>ein Budget</accent> + Budget speichern + Zahlungsanforderungen von {name} unterhalb dieser Limits automatisch genehmigen: + Anforderungen bis {perPayment} werden automatisch bezahlt, bis zu {monthly} pro Monat, ohne dich jedes Mal zu fragen. + Budget festlegen + Aktiv + Erfordert Aufmerksamkeit + Abgelehnt + Beendet + Abgelaufen + Wartet auf deine Antwort + Noch nicht aktiv + Wartet auf Antwort + Wischen zum Beenden des Budgets + Wischen zum Zurückziehen + Budget + Bis zu {amount} + Budgets + Lege Ausgabenlimits fest, um Zahlungsanforderungen von vertrauenswürdigen Kontakten automatisch zu erfüllen. + Richte deine\n<accent>Budgets</accent> ein Abonnement kündigen Empfänger wählen Kürze den Namen oder die Beschreibung des Abonnements und versuche es erneut. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index e59576a82a..d2f008dc41 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1463,6 +1463,53 @@ Αδυναμία πληρωμής επαφής Αναμονή για ενημέρωση των στοιχείων πληρωμής της επαφής. Δοκίμασε ξανά σε λίγο. Ενεργή + Αποδοχή + Προσθήκη προϋπολογισμού + Πληρώθηκε αυτόματα + Επιλογή επαφής + Απόρριψη + Τα αιτήματά σου εντός αυτών των ορίων πληρώνονται αυτόματα. + Τα αιτήματα εντός αυτών των ορίων πληρώνονται αυτόματα. Για οτιδήποτε πάνω από αυτά, θα ερωτηθείς πρώτα. + Αυτός ο προϋπολογισμός τερματίστηκε. Κάθε αίτημα θα ζητά ξανά την έγκρισή σου. + Κάθε μήνα + Αυτή η επαφή δεν είναι ακόμη συνδεδεμένη. Δοκίμασε ξανά σε λίγο. + Αυτός ο προϋπολογισμός δεν είναι διαθέσιμος αυτή τη στιγμή. + Η αυτόματη πληρωμή έστειλε {amount} σε {name} + Η πληρωμή εκτελέστηκε + Ένα αίτημα {amount} από {name} υπερβαίνει τον προϋπολογισμό σου. Έλεγξέ το για να πληρώσεις. + Το όριο συμπληρώθηκε + Μηνιαίος προϋπολογισμός + Μηνιαίο όριο + Πρόσθεσε πρώτα μια επαφή. Οι προϋπολογισμοί καλύπτουν αιτήματα πληρωμής από τις επαφές σου. + Το πορτοφόλι της επαφής {name} θα πληρώνει τα αιτήματά σου εντός αυτών των ορίων χωρίς να ρωτά κάθε φορά. Οποιοσδήποτε από τους δύο μπορεί να τον τερματίσει. + {name} σού προσφέρει\n<accent>έναν προϋπολογισμό</accent> + {amount} πληρώθηκαν αυτόματα + Πληρώθηκαν αυτόματα + Αυτό το αίτημα πληρώνεται ήδη αυτόματα. + Όριο πληρωμής + Ανά πληρωμή + {amount} ανά πληρωμή + Το πορτοφόλι σου θα πληρώνει τα αιτήματα της επαφής {name} εντός αυτών των ορίων χωρίς να σε ρωτά κάθε φορά. Οποιοσδήποτε από τους δύο μπορεί να τον τερματίσει. + {name} ζητά\n<accent>έναν προϋπολογισμό</accent> + Αποθήκευση προϋπολογισμού + Αυτόματη έγκριση αιτημάτων πληρωμής από {name} κάτω από αυτά τα όρια: + Τα αιτήματα έως {perPayment} θα πληρώνονται αυτόματα, έως {monthly} τον μήνα, χωρίς να σε ρωτάμε κάθε φορά. + Ορισμός προϋπολογισμού + Ενεργός + Χρειάζεται προσοχή + Απορρίφθηκε + Τερματίστηκε + Έληξε + Αναμονή για την απάντησή σου + Δεν είναι ακόμη ενεργός + Αναμονή για απάντηση + Σύρε για τερματισμό προϋπολογισμού + Σύρε για απόσυρση + Προϋπολογισμός + Έως {amount} + Προϋπολογισμοί + Μπορείς να ορίσεις όρια δαπανών για να εκπληρώνονται αυτόματα αιτήματα πληρωμής από αξιόπιστες επαφές. + Ρύθμισε\n<accent>προϋπολογισμούς</accent> Ακύρωση συνδρομής Επιλογή παραλήπτη Συντόμευσε το όνομα ή την περιγραφή της συνδρομής και δοκίμασε ξανά. diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 3c464fa803..ce4b894458 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -1467,6 +1467,53 @@ No se puede pagar al contacto Esperando a que el contacto actualice sus datos de pago. Inténtalo de nuevo en breve. Activa + Aceptar + Añadir asignación + Pagado automáticamente + Elegir contacto + Rechazar + Tus solicitudes dentro de estos límites se pagan automáticamente. + Las solicitudes dentro de estos límites se pagan automáticamente. Para cualquier importe superior, se te preguntará primero. + Esta asignación ha finalizado. Cada solicitud volverá a pedir tu aprobación. + Cada mes + Este contacto aún no está conectado. Inténtalo de nuevo en un momento. + Esta asignación no está disponible en este momento. + El pago automático envió {amount} a {name} + Pago ejecutado + Una solicitud de {amount} de {name} supera tu asignación. Revísala para pagarla. + Límite alcanzado + Asignación mensual + Límite mensual + Primero añade un contacto. Las asignaciones cubren las solicitudes de pago de tus contactos. + El monedero de {name} pagará tus solicitudes dentro de estos límites sin preguntar cada vez. Cualquiera de los dos puede finalizarla. + {name} te ofrece\n<accent>una asignación</accent> + {amount} pagado automáticamente + Pagado automáticamente + Esta solicitud ya se está pagando automáticamente. + Límite de pago + Por pago + {amount} por pago + Tu monedero pagará las solicitudes de {name} dentro de estos límites sin preguntarte cada vez. Cualquiera de los dos puede finalizarla. + {name} solicita\n<accent>una asignación</accent> + Guardar asignación + Aprobar automáticamente las solicitudes de pago de {name} por debajo de estos límites: + Las solicitudes de hasta {perPayment} se pagarán automáticamente, hasta {monthly} al mes, sin preguntarte cada vez. + Establecer asignación + Activa + Requiere atención + Rechazada + Finalizada + Caducada + Esperando tu respuesta + Aún no activa + Esperando una respuesta + Deslizar para finalizar la asignación + Deslizar para retirar + Asignación + Hasta {amount} + Asignaciones + Puedes establecer límites de gasto para pagar automáticamente las solicitudes de pago de contactos de confianza. + Configura\n<accent>asignaciones</accent> Cancelar suscripción Elegir destinatario Acorta el nombre o la descripción de la suscripción e inténtalo de nuevo. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 215c021b3c..4a12f3a47b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1467,6 +1467,53 @@ Esperando a que el contacto actualice sus datos de pago. Inténtelo de nuevo en breve. Pagos aceptados Activa + Aceptar + Añadir asignación + Pagado automáticamente + Elegir contacto + Rechazar + Sus solicitudes dentro de estos límites se pagan automáticamente. + Las solicitudes dentro de estos límites se pagan automáticamente. Para cualquier importe superior, se le preguntará primero. + Esta asignación ha finalizado. Cada solicitud volverá a pedir su aprobación. + Cada mes + Este contacto aún no está conectado. Inténtelo de nuevo en un momento. + Esta asignación no está disponible en este momento. + El pago automático envió {amount} a {name} + Pago ejecutado + Una solicitud de {amount} de {name} supera su asignación. Revísela para pagarla. + Límite alcanzado + Asignación mensual + Límite mensual + Primero añada un contacto. Las asignaciones cubren las solicitudes de pago de sus contactos. + El monedero de {name} pagará sus solicitudes dentro de estos límites sin preguntar cada vez. Cualquiera de los dos puede finalizarla. + {name} le ofrece\n<accent>una asignación</accent> + {amount} pagado automáticamente + Pagado automáticamente + Esta solicitud ya se está pagando automáticamente. + Límite de pago + Por pago + {amount} por pago + Su monedero pagará las solicitudes de {name} dentro de estos límites sin preguntarle cada vez. Cualquiera de los dos puede finalizarla. + {name} solicita\n<accent>una asignación</accent> + Guardar asignación + Aprobar automáticamente las solicitudes de pago de {name} por debajo de estos límites: + Las solicitudes de hasta {perPayment} se pagarán automáticamente, hasta {monthly} al mes, sin preguntarle cada vez. + Establecer asignación + Activa + Requiere atención + Rechazada + Finalizada + Caducada + Esperando su respuesta + Aún no activa + Esperando una respuesta + Deslizar para finalizar la asignación + Deslizar para retirar + Asignación + Hasta {amount} + Asignaciones + Puede establecer límites de gasto para pagar automáticamente las solicitudes de pago de contactos de confianza. + Configure\n<accent>asignaciones</accent> Cancelar suscripción Elegir destinatario Acorte el nombre o la descripción de la suscripción e inténtelo de nuevo. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 3bff9a28e1..ea86fd2499 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1468,6 +1468,53 @@ En attente de la mise à jour des informations de paiement du contact. Veuillez réessayer sous peu. Paiements acceptés Actif + Accepter + Ajouter une allocation + Payé automatiquement + Choisir un contact + Refuser + Vos demandes dans ces limites sont payées automatiquement. + Les demandes dans ces limites sont payées automatiquement. Au-delà, votre accord vous est d\'abord demandé. + Cette allocation a pris fin. Chaque demande nécessite à nouveau votre accord. + Chaque mois + Ce contact n\'est pas encore connecté. Réessayez dans un instant. + Cette allocation n\'est pas disponible pour le moment. + Paiement automatique de {amount} envoyé à {name} + Paiement effectué + Une demande de {amount} de {name} dépasse votre allocation. Vérifiez-la pour la payer. + Limite atteinte + Allocation mensuelle + Limite mensuelle + Ajoutez d\'abord un contact. Les allocations couvrent les demandes de paiement de vos contacts. + Le portefeuille de {name} paiera vos demandes dans ces limites sans demander à chaque fois. Chacun de vous peut y mettre fin. + {name} vous offre\n<accent>une allocation</accent> + {amount} payé automatiquement + Payé automatiquement + Cette demande est déjà en cours de paiement automatique. + Limite de paiement + Par paiement + {amount} par paiement + Votre portefeuille paiera les demandes de {name} dans ces limites sans vous demander à chaque fois. Chacun de vous peut y mettre fin. + {name} demande\n<accent>une allocation</accent> + Enregistrer l\'allocation + Approuver automatiquement les demandes de paiement de {name} en dessous de ces limites : + Les demandes jusqu\'à {perPayment} seront payées automatiquement, jusqu\'à {monthly} par mois, sans vous demander à chaque fois. + Définir l\'allocation + Active + Action requise + Refusée + Terminée + Expirée + En attente de votre réponse + Pas encore active + En attente d\'une réponse + Glissez pour mettre fin à l\'allocation + Glissez pour retirer + Allocation + Jusqu\'à {amount} + Allocations + Vous pouvez définir des limites de dépenses pour régler automatiquement les demandes de paiement de contacts de confiance. + Configurez\n<accent>vos allocations</accent> Annuler l\'abonnement Choisir le destinataire Raccourcissez le nom ou la description de l\'abonnement et réessayez. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 0b99b9d7dd..1baa5020fe 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1471,6 +1471,53 @@ Nessun endpoint di pagamento compatibile disponibile. In attesa che il contatto aggiorni i suoi dati di pagamento. Riprova a breve. Attivo + Accetta + Aggiungi Budget + Pagato automaticamente + Scegli Contatto + Rifiuta + Le tue richieste entro questi limiti vengono pagate automaticamente. + Le richieste entro questi limiti vengono pagate automaticamente. Per importi superiori ti verrà chiesto prima. + Questo budget è terminato. Ogni richiesta chiederà di nuovo la tua approvazione. + Ogni mese + Questo contatto non è ancora connesso. Riprova tra un momento. + Questo budget non è disponibile al momento. + Il pagamento automatico ha inviato {amount} a {name} + Pagamento Eseguito + Una richiesta di {amount} da {name} supera il tuo budget. Controllala per pagarla. + Limite Raggiunto + Budget mensile + Limite mensile + Aggiungi prima un contatto. I budget coprono le richieste di pagamento dei tuoi contatti. + Il wallet di {name} pagherà le tue richieste entro questi limiti senza chiedere ogni volta. Ognuno di voi può terminarlo. + {name} ti offre\n<accent>un budget</accent> + {amount} pagati automaticamente + Pagato automaticamente + Questa richiesta è già in fase di pagamento automatico. + Limite di pagamento + Per pagamento + {amount} a pagamento + Il tuo wallet pagherà le richieste di {name} entro questi limiti senza chiederti ogni volta. Ognuno di voi può terminarlo. + {name} chiede\n<accent>un budget</accent> + Salva Budget + Approva automaticamente le richieste di pagamento di {name} al di sotto di questi limiti: + Le richieste fino a {perPayment} verranno pagate automaticamente, fino a {monthly} al mese, senza chiederti ogni volta. + Imposta Budget + Attivo + Richiede attenzione + Rifiutato + Terminato + Scaduto + In attesa della tua risposta + Non ancora attivo + In attesa di risposta + Scorri Per Terminare il Budget + Scorri Per Ritirare + Budget + Fino a {amount} + Budget + Puoi impostare limiti di spesa per soddisfare automaticamente le richieste di pagamento di contatti fidati. + Configura i tuoi\n<accent>budget</accent> Annulla Abbonamento Scegli Destinatario Accorcia il nome o la descrizione dell\'abbonamento e riprova. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 02d12a586d..e5ba0e3cd1 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1469,6 +1469,53 @@ Contact kan niet worden betaald Wachten tot het contact de betaalgegevens bijwerkt. Probeer het zo opnieuw. Actief + Accepteren + Toelage toevoegen + Automatisch betaald + Contact kiezen + Weigeren + Uw verzoeken binnen deze limieten worden automatisch betaald. + Verzoeken binnen deze limieten worden automatisch betaald. Voor alles daarboven wordt u eerst om toestemming gevraagd. + Deze toelage is beëindigd. Voor elk verzoek wordt weer om toestemming gevraagd. + Elke maand + Dit contact is nog niet verbonden. Probeer het zo opnieuw. + Deze toelage is momenteel niet beschikbaar. + Automatische betaling van {amount} verzonden naar {name} + Betaling uitgevoerd + Een verzoek van {name} voor {amount} overschrijdt uw toelage. Bekijk het om te betalen. + Limiet bereikt + Maandelijkse toelage + Maandlimiet + Voeg eerst een contact toe. Toelagen gelden voor betaalverzoeken van uw contacten. + De wallet van {name} betaalt uw verzoeken binnen deze limieten zonder elke keer te vragen. Beide partijen kunnen de toelage beëindigen. + {name} biedt\n<accent>een toelage</accent> aan + {amount} automatisch betaald + Automatisch betaald + Dit verzoek wordt al automatisch betaald. + Betalingslimiet + Per betaling + {amount} per betaling + Uw wallet betaalt de verzoeken van {name} binnen deze limieten zonder u elke keer te vragen. Beide partijen kunnen de toelage beëindigen. + {name} vraagt om\n<accent>een toelage</accent> + Toelage opslaan + Betaalverzoeken van {name} onder deze limieten automatisch goedkeuren: + Verzoeken tot {perPayment} worden automatisch betaald, tot {monthly} per maand, zonder u elke keer te vragen. + Toelage instellen + Actief + Vereist aandacht + Geweigerd + Beëindigd + Verlopen + Wacht op uw antwoord + Nog niet actief + Wacht op een antwoord + Veeg om toelage te beëindigen + Veeg om in te trekken + Toelage + Tot {amount} + Toelagen + U kunt bestedingslimieten instellen om betaalverzoeken van vertrouwde contacten automatisch te voldoen. + Stel\n<accent>toelagen</accent> in Abonnement opzeggen Ontvanger kiezen Maak de naam of beschrijving van het abonnement korter en probeer het opnieuw. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index af79197cb0..a0bd13203d 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1470,6 +1470,53 @@ Oczekiwanie, aż kontakt zaktualizuje swoje dane płatności. Spróbuj ponownie za chwilę. Akceptowane płatności Aktywne + Akceptuj + Dodaj budżet + Opłacono automatycznie + Wybierz kontakt + Odrzuć + Twoje żądania w ramach tych limitów są opłacane automatycznie. + Żądania w ramach tych limitów są opłacane automatycznie. O wszystko powyżej zostaniesz najpierw zapytany. + Ten budżet się zakończył. Każde żądanie ponownie wymaga Twojej zgody. + Co miesiąc + Ten kontakt nie jest jeszcze połączony. Spróbuj ponownie za chwilę. + Ten budżet jest teraz niedostępny. + Automatyczna płatność wysłała {amount} do {name} + Płatność wykonana + Żądanie {amount} od {name} przekracza Twój budżet. Sprawdź je, aby zapłacić. + Osiągnięto limit + Budżet miesięczny + Limit miesięczny + Najpierw dodaj kontakt. Budżety obejmują żądania płatności od Twoich kontaktów. + Portfel {name} będzie opłacać Twoje żądania w ramach tych limitów bez pytania za każdym razem. Każda ze stron może go zakończyć. + {name} oferuje\n<accent>budżet</accent> + {amount} opłacono automatycznie + Opłacono automatycznie + To żądanie jest już opłacane automatycznie. + Limit płatności + Na płatność + {amount} na płatność + Twój portfel będzie opłacać żądania {name} w ramach tych limitów bez pytania Cię za każdym razem. Każda ze stron może go zakończyć. + {name} prosi o\n<accent>budżet</accent> + Zapisz budżet + Automatycznie zatwierdzaj żądania płatności od {name} poniżej tych limitów: + Żądania do {perPayment} będą opłacane automatycznie, do {monthly} miesięcznie, bez pytania Cię za każdym razem. + Ustaw budżet + Aktywny + Wymaga uwagi + Odrzucony + Zakończony + Wygasł + Czeka na Twoją odpowiedź + Jeszcze nieaktywny + Czeka na odpowiedź + Przesuń, aby zakończyć budżet + Przesuń, aby wycofać + Budżet + Do {amount} + Budżety + Możesz ustawić limity wydatków, aby automatycznie realizować żądania płatności od zaufanych kontaktów. + Skonfiguruj\n<accent>budżety</accent> Anuluj subskrypcję Wybierz odbiorcę Skróć nazwę lub opis subskrypcji i spróbuj ponownie. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index c67faa0a75..40d092d2e2 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1471,6 +1471,53 @@ Nenhum endpoint de pagamento compatível está disponível. Aguardando o contato atualizar os dados de pagamento. Tente novamente em breve. Ativa + Aceitar + Adicionar Mesada + Pago automaticamente + Escolher Contato + Recusar + Suas solicitações dentro destes limites são pagas automaticamente. + Solicitações dentro destes limites são pagas automaticamente. Qualquer valor acima pedirá sua confirmação primeiro. + Esta mesada foi encerrada. Cada solicitação pedirá sua confirmação novamente. + Todo mês + Este contato ainda não está conectado. Tente novamente em instantes. + Esta mesada não está disponível no momento. + O pagamento automático enviou {amount} para {name} + Pagamento Executado + Uma solicitação de {amount} de {name} está acima da sua mesada. Revise-a para pagar. + Limite Atingido + Mesada mensal + Limite mensal + Adicione um contato primeiro. As mesadas cobrem solicitações de pagamento dos seus contatos. + A carteira de {name} pagará suas solicitações dentro destes limites sem perguntar a cada vez. Qualquer um de vocês pode encerrá-la. + {name} oferece\n<accent>uma mesada</accent> + {amount} pago automaticamente + Pago automaticamente + Esta solicitação já está sendo paga automaticamente. + Limite de pagamento + Por pagamento + {amount} por pagamento + Sua carteira pagará as solicitações de {name} dentro destes limites sem perguntar a você a cada vez. Qualquer um de vocês pode encerrá-la. + {name} pede\n<accent>uma mesada</accent> + Salvar Mesada + Aprovar automaticamente solicitações de pagamento de {name} abaixo destes limites: + Solicitações de até {perPayment} serão pagas automaticamente, até {monthly} por mês, sem perguntar a você a cada vez. + Definir Mesada + Ativa + Requer atenção + Recusada + Encerrada + Expirada + Aguardando sua resposta + Ainda não ativa + Aguardando uma resposta + Deslize para Encerrar a Mesada + Deslize para Retirar + Mesada + Até {amount} + Mesadas + Você pode definir limites de gastos para pagar automaticamente solicitações de pagamento de contatos de confiança. + Configure\n<accent>mesadas</accent> Cancelar Assinatura Escolher Destinatário Encurte o nome ou a descrição da assinatura e tente novamente. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 1d94fe96b1..2a9cf41e87 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1457,6 +1457,53 @@ A aguardar que o contacto atualize os dados de pagamento. Tente novamente daqui a pouco. Pagamentos aceites Ativa + Aceitar + Adicionar mesada + Pago automaticamente + Escolher contacto + Recusar + Os seus pedidos dentro destes limites são pagos automaticamente. + Os pedidos dentro destes limites são pagos automaticamente. Qualquer valor acima pede primeiro a sua confirmação. + Esta mesada terminou. Cada pedido volta a pedir a sua confirmação. + Todos os meses + Este contacto ainda não está ligado. Tente novamente daqui a pouco. + Esta mesada não está disponível de momento. + O pagamento automático enviou {amount} para {name} + Pagamento executado + Um pedido de {amount} de {name} excede a sua mesada. Reveja-o para pagar. + Limite atingido + Mesada mensal + Limite mensal + Adicione primeiro um contacto. As mesadas cobrem pedidos de pagamento dos seus contactos. + A carteira de {name} pagará os seus pedidos dentro destes limites sem perguntar de cada vez. Qualquer uma das partes pode terminá-la. + {name} oferece\n<accent>uma mesada</accent> + {amount} pago automaticamente + Pago automaticamente + Este pedido já está a ser pago automaticamente. + Limite de pagamento + Por pagamento + {amount} por pagamento + A sua carteira pagará os pedidos de {name} dentro destes limites sem lhe perguntar de cada vez. Qualquer uma das partes pode terminá-la. + {name} pede\n<accent>uma mesada</accent> + Guardar mesada + Aprovar automaticamente pedidos de pagamento de {name} abaixo destes limites: + Os pedidos até {perPayment} serão pagos automaticamente, até {monthly} por mês, sem lhe perguntar de cada vez. + Definir mesada + Ativa + Requer atenção + Recusada + Terminada + Expirada + A aguardar a sua resposta + Ainda não ativa + A aguardar resposta + Deslize para terminar a mesada + Deslize para retirar + Mesada + Até {amount} + Mesadas + Pode definir limites de gastos para pagar automaticamente pedidos de pagamento de contactos de confiança. + Configure\n<accent>mesadas</accent> Cancelar subscrição Escolher destinatário Encurte o nome ou a descrição da subscrição e tente novamente. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index b2c5b09f55..bb02ac51e7 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1499,6 +1499,53 @@ Bitkit должен увеличить приёмную ёмкость ваше Нет доступного совместимого платёжного адреса. Ожидаем, пока контакт обновит свои платёжные данные. Пожалуйста, попробуйте чуть позже. Активна + Принять + Добавить Бюджет + Оплачено автоматически + Выберите Контакт + Отклонить + Ваши запросы в пределах этих лимитов оплачиваются автоматически. + Запросы в пределах этих лимитов оплачиваются автоматически. Всё, что выше, сначала потребует вашего подтверждения. + Этот бюджет завершён. Каждый запрос снова потребует подтверждения. + Каждый месяц + Этот контакт ещё не подключён. Попробуйте снова через минуту. + Этот бюджет сейчас недоступен. + Автоплатёж отправил {amount} получателю {name} + Платёж Выполнен + Запрос на {amount} от {name} превышает ваш бюджет. Проверьте его, чтобы оплатить. + Лимит Достигнут + Ежемесячный бюджет + Месячный лимит + Сначала добавьте контакт. Бюджеты распространяются на запросы на оплату от ваших контактов. + Кошелёк {name} будет оплачивать ваши запросы в пределах этих лимитов, не спрашивая каждый раз. Любая из сторон может его завершить. + {name} предлагает\n<accent>бюджет</accent> + {amount} оплачено автоматически + Оплачено автоматически + Этот запрос уже оплачивается автоматически. + Лимит платежа + За платёж + {amount} за платёж + Ваш кошелёк будет оплачивать запросы {name} в пределах этих лимитов, не спрашивая вас каждый раз. Любая из сторон может его завершить. + {name} просит\n<accent>бюджет</accent> + Сохранить Бюджет + Автоматически одобрять запросы на оплату от {name} в пределах этих лимитов: + Запросы до {perPayment} будут оплачиваться автоматически, до {monthly} в месяц, без подтверждения каждый раз. + Установить Бюджет + Активен + Требует внимания + Отклонён + Завершён + Истёк + Ожидает вашего ответа + Ещё не активен + Ожидает ответа + Проведите для Завершения Бюджета + Проведите для Отзыва + Бюджет + До {amount} + Бюджеты + Вы можете задать лимиты расходов, чтобы автоматически оплачивать запросы на оплату от доверенных контактов. + Настройте\n<accent>бюджеты</accent> Отменить Подписку Выберите Получателя Сократите название или описание подписки и попробуйте снова. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8187e20fbc..96ff21f7d3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1085,6 +1085,53 @@ Waiting for the contact to update their payment details. Please try again shortly. Accepted payments Active + Accept + Add Allowance + Auto-paid + Choose Contact + Decline + Your requests within these limits are paid automatically. + Requests within these limits are paid automatically. Anything above them asks you first. + This allowance has ended. Every request asks again. + Each month + This contact is not connected yet. Try again in a moment. + This allowance is not available right now. + Auto-pay sent {amount} to {name} + Payment Executed + A {amount} request from {name} is above your allowance. Review it to pay. + Limit Reached + Monthly allowance + Monthly limit + Add a contact first. Allowances cover payment requests from your contacts. + {name}\'s wallet will pay your requests within these limits without asking each time. Either of you can end it. + an allowance]]> + {amount} paid automatically + Paid automatically + This request is already being paid automatically. + Payment limit + Per payment + {amount} a payment + Your wallet will pay {name}\'s requests within these limits without asking you each time. Either of you can end it. + an allowance]]> + Save Allowance + Automatically approve payment requests from {name} below these limits: + Requests up to {perPayment} will be paid automatically, up to {monthly} a month, without asking you each time. + Set Allowance + Active + Needs attention + Declined + Ended + Expired + Waiting for your answer + Not active yet + Waiting for an answer + Swipe To End Allowance + Swipe To Withdraw + Allowance + Up to {amount} + Allowances + You can set spending limits to automatically fulfill payment requests from trusted peers. + allowances]]> Cancel Cancel Subscription Choose Recipient diff --git a/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt b/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt index 03a5a3f8a2..57d40557c6 100644 --- a/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt +++ b/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt @@ -35,6 +35,23 @@ class PaykitPaymentStateBackupTest { } } + @Test + fun `allowance proof keeps its allowance id through backup`() { + WalletScope.pushTestOverride("wallet0").use { + val backup = Json.decodeFromString( + """ + {"identity":"alice","requestId":{"paymentRequestId":"request","counterparty":"bob"}, + "paymentEndpointIdentifier":"bitcoin-bolt11","kind":"bitcoin-bolt11-preimage","paymentStarted":true, + "onchainMatchingTransactionIdsBeforeAttempt":[],"allowanceId":"allowance-1"} + """.trimIndent(), + ) + val restored = backup.restored() + assertEquals("allowance-1", restored.allowanceId) + val json = Json.encodeToString(PaykitPaymentStateBackup.Proof(restored)) + assertContains(json, "\"allowanceId\":\"allowance-1\"") + } + } + @Test fun `shared active operation survives payment backup reader writer roundtrip`() { val wire = """ diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt new file mode 100644 index 0000000000..5e53522ca9 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt @@ -0,0 +1,1201 @@ +package to.bitkit.repositories + +import com.synonym.paykit.AllowanceAccountingBlock +import com.synonym.paykit.AllowanceAccountingReconciliation +import com.synonym.paykit.AllowanceAccountingState +import com.synonym.paykit.AllowanceAssociationRecord +import com.synonym.paykit.AllowanceAssociationRevision +import com.synonym.paykit.AllowanceCandidate +import com.synonym.paykit.AllowanceLifecycleState +import com.synonym.paykit.AllowanceSelectionInput +import com.synonym.paykit.ConversionRate +import com.synonym.paykit.OutboundPrivateSendReport +import com.synonym.paykit.PaymentAccountingScope +import com.synonym.paykit.PaymentAttemptDecision +import com.synonym.paykit.PaymentConversion +import com.synonym.paykit.PaymentDisposition +import com.synonym.paykit.PaymentExecutionChecks +import com.synonym.paykit.PaymentExecutionMode +import com.synonym.paykit.PaymentExecutionStatus +import com.synonym.paykit.PaymentOccurrence +import com.synonym.paykit.PaymentOccurrenceKey +import com.synonym.paykit.PaymentOccurrenceRecord +import com.synonym.paykit.PaymentOutcome +import com.synonym.paykit.PaymentOutcomeReport +import com.synonym.paykit.PaymentRequestRecord +import com.synonym.paykit.PaymentRequestScope +import com.synonym.paykit.PrivateStreamIntakeReport +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import org.junit.After +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.lightningdevkit.ldknode.PaymentStatus +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import to.bitkit.data.keychain.Keychain +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset +import to.bitkit.models.PaykitRequestPricing +import to.bitkit.repositories.PaykitAllowanceFixtures.ALLOWANCE_ID +import to.bitkit.repositories.PaykitAllowanceLocalState.JournalEntry +import to.bitkit.repositories.PaykitAllowanceLocalState.Stage +import to.bitkit.services.PaykitSdkService +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError +import to.bitkit.utils.SubscriptionClockOffset +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +@Suppress("LargeClass") +class PaykitAllowanceExecutorTest : BaseUnitTest() { + companion object { + private const val AUTOMATIC_ATTEMPT_ID = "attempt-1" + private const val MANUAL_ATTEMPT_ID = "manual-1" + private const val INVOICE = "lnbcrt10u1allowancetestinvoice" + private const val ONCHAIN_ADDRESS = "bcrt1qallowancetestaddress" + private const val PAYMENT_APP_ID = "bitkit" + private val PAYMENT_HASH = "ab".repeat(32) + } + + private val fixtures = PaykitAllowanceFixtures + private val identity = fixtures.identityKey + private val sdk = mock() + private val payer = mock() + private val keychain = mock() + private var storedState: String? = null + private var failSave: (String) -> Boolean = { false } + private var now = fixtures.now + private val clock = object : Clock { + override fun now(): Instant = this@PaykitAllowanceExecutorTest.now + } + + private var accountingState: AllowanceAccountingState? = fixtures.accountingState() + private var candidates = listOf(candidate()) + private var automaticReservation: PaymentAttemptDecision? = null + private var manualReservation: PaymentAttemptDecision? = null + private var beginDecision: PaymentAttemptDecision? = null + private val evaluatedTrustedTimes = mutableListOf() + private val selections = mutableListOf() + private val acceptedChecks = mutableListOf() + private val reservedAssociationRevisions = mutableListOf() + private val recordedOutcomes = mutableListOf() + private val reconciliations = mutableListOf() + private val nodeStatuses = mutableMapOf() + private val events = mutableListOf() + private lateinit var sut: PaykitAllowanceExecutor + + @Before + fun setUp() = test { + stubLedger() + stubAttempts() + stubPayer() + val amount = fixtures.accountingAmount("0.00001") + sut = PaykitAllowanceExecutor( + ioDispatcher = testDispatcher, + paykitSdkService = sdk, + store = PaykitAllowanceStore(keychain), + payer = payer, + clock = clock, + accountingAmount = { _, _ -> amount }, + ) + } + + private suspend fun stubLedger() { + whenever(keychain.loadString(any())).thenAnswer { storedState } + whenever(keychain.upsertString(any(), any())).doSuspendableAnswer { + val state = it.getArgument(1) + if (failSave(state)) throw TestAllowanceError("save") + storedState = state + } + + whenever(sdk.allowanceAccountingState()).thenAnswer { accountingState } + whenever(sdk.reconcileAllowanceAccounting(any())).doSuspendableAnswer { + val reconciliation = it.getArgument(0) + reconciliations += reconciliation + AllowanceAccountingState( + revision = (reconciliation.expectedRevision ?: 0uL) + 1uL, + epoch = accountingState?.epoch ?: "epoch-1", + requiresReconciliation = false, + history = reconciliation.history, + ).also { reconciled -> accountingState = reconciled } + } + whenever(sdk.evaluateAllowanceCandidates(any(), any())).doSuspendableAnswer { + evaluatedTrustedTimes += it.getArgument(1) + candidates + } + whenever(sdk.acceptPaymentRequestAutomatically(any(), any(), any())).doSuspendableAnswer { + val selection = it.getArgument(1) + selections += selection + acceptedChecks += it.getArgument(2) + AllowanceAssociationRecord( + request = fixtures.accountingScope(it.getArgument(0).paymentRequestId), + revisions = listOf( + AllowanceAssociationRevision( + revision = 1uL, + allowanceId = selection.allowanceId, + effectiveFrom = null, + authorizationId = null, + authorizedAt = selection.trustedTime, + ), + ), + ) + } + whenever(sdk.claimPaymentRequestForExecution(any(), any())).thenReturn(mock()) + whenever(sdk.processOutboundPrivateMessages(any())) + .thenReturn(OutboundPrivateSendReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList())) + whenever(sdk.receivePrivateMessages(any())) + .thenReturn(PrivateStreamIntakeReport(null, emptyList(), emptyList())) + } + + private suspend fun stubAttempts() { + val preparedAutomatic = PaymentAttemptDecision.Ready( + fixtures.attemptRecord(AUTOMATIC_ATTEMPT_ID, status = PaymentExecutionStatus.PREPARED), + ) + val submittedAutomatic = PaymentAttemptDecision.Ready( + fixtures.attemptRecord(AUTOMATIC_ATTEMPT_ID, status = PaymentExecutionStatus.SUBMITTED), + ) + val preparedManual = PaymentAttemptDecision.Ready( + fixtures.attemptRecord( + MANUAL_ATTEMPT_ID, + mode = PaymentExecutionMode.MANUAL, + allowanceId = null, + status = PaymentExecutionStatus.PREPARED, + ), + ) + val submittedManual = PaymentAttemptDecision.Ready( + fixtures.attemptRecord( + MANUAL_ATTEMPT_ID, + mode = PaymentExecutionMode.MANUAL, + allowanceId = null, + status = PaymentExecutionStatus.SUBMITTED, + ), + ) + val recordedAttempt = fixtures.attemptRecord(AUTOMATIC_ATTEMPT_ID, status = PaymentExecutionStatus.SUCCEEDED) + + whenever(sdk.reserveAutomaticPayment(any(), any(), any())).doSuspendableAnswer { + reservedAssociationRevisions += (it.arguments[1] as Long).toULong() + automaticReservation ?: preparedAutomatic + } + whenever(sdk.reserveManualPayment(any(), any())).doSuspendableAnswer { manualReservation ?: preparedManual } + whenever(sdk.beginPaymentExecution(any(), any())).doSuspendableAnswer { + beginDecision ?: if (it.getArgument(0) == MANUAL_ATTEMPT_ID) submittedManual else submittedAutomatic + } + whenever(sdk.recordPaymentOutcome(any())).doSuspendableAnswer { + recordedOutcomes += it.getArgument(0) + recordedAttempt + } + whenever(sdk.markPaymentManualOnly(any())).doSuspendableAnswer { + PaymentOccurrenceRecord( + key = PaymentOccurrenceKey(fixtures.accountingScope("manual-only"), billingPeriod = null), + disposition = PaymentDisposition.ManualOnly, + allowanceId = null, + associationRevision = null, + attempts = emptyList(), + ) + } + } + + private suspend fun stubPayer() { + whenever(payer.resolve(any(), any())).thenReturn(Result.success(lightningPayment())) + whenever(payer.acceptOnThisInstall(any(), any())).doSuspendableAnswer { + runCatching { it.getArgument AllowanceAssociationRecord>(1).invoke() } + } + whenever(payer.consumePaymentList(any(), any())).thenReturn(Result.success(Unit)) + whenever(payer.releasePaymentList(any(), any())).thenReturn(Result.success(Unit)) + whenever(payer.prepareProof(any(), any(), any(), anyOrNull())).thenReturn(Result.success(Unit)) + whenever(payer.associateLightningPayment(any(), any(), any(), any())).thenReturn(Result.success(Unit)) + whenever(payer.payLightning(any(), anyOrNull(), anyOrNull(), any())).thenReturn(Result.success(PAYMENT_HASH)) + whenever(payer.failLightningPayment(any(), any())).thenReturn(true) + whenever(payer.lightningPaymentStatus(any())).thenAnswer { nodeStatuses[it.getArgument(0)] } + } + + // region Admission + + @Test + fun `uncovered request never reaches the SDK`() = test { + val request = fixtures.paymentRequest() + val uncovering = listOf( + emptyList(), + listOf(fixtures.allowance(role = PaykitAllowance.Role.ALLOWEE)), + listOf(fixtures.allowance(state = AllowanceLifecycleState.PROPOSED)), + listOf(fixtures.allowance(state = AllowanceLifecycleState.ENDED)), + listOf(fixtures.allowance(counterparty = fixtures.otherCounterpartyKey)), + ) + + for (allowances in uncovering) { + assertEquals(PaykitAllowanceAutoPayResult.NOT_COVERED, sut.autoPay(request, allowances, identity)) + } + verifyNoInteractions(sdk, payer, keychain) + } + + @Test + fun `covered lightning request runs admission in order and hands off to the node`() = test { + val request = fixtures.paymentRequest() + + val result = sut.autoPay(request, listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.STARTED, result) + val order = inOrder(sdk, payer) + order.verify(sdk).evaluateAllowanceCandidates(any(), any()) + order.verify(payer).resolve(eq(request), eq(listOf(fixtures.lightningIdentifier))) + order.verify(payer).acceptOnThisInstall(eq(request), any()) + order.verify(sdk).claimPaymentRequestForExecution(request.counterparty, request.paymentRequestId) + order.verify(sdk).acceptPaymentRequestAutomatically(any(), any(), any()) + order.verify(sdk).reserveAutomaticPayment(any(), any(), any()) + order.verify(sdk).receivePrivateMessages(request.counterparty) + order.verify(sdk).beginPaymentExecution(eq(AUTOMATIC_ATTEMPT_ID), any()) + order.verify(payer).consumePaymentList(request.counterparty, lightningPayment().context) + order.verify(payer).prepareProof(request, fixtures.lightningIdentifier, PAYMENT_APP_ID, ALLOWANCE_ID) + order.verify(payer).associateLightningPayment( + request, + PAYMENT_HASH, + fixtures.lightningIdentifier, + PAYMENT_APP_ID, + ) + order.verify(payer).payLightning(eq(INVOICE), isNull(), anyOrNull(), any()) + verify(sdk, never()).recordPaymentOutcome(any()) + assertEquals(listOf(PaykitAllowanceTime.format(fixtures.now)), evaluatedTrustedTimes) + assertEquals(listOf(ALLOWANCE_ID), selections.map { it.allowanceId }) + assertEquals(listOf(fixtures.lightningIdentifier), acceptedChecks.map { it.paymentEndpointIdentifier }) + assertEquals(listOf(1uL), reservedAssociationRevisions) + + val entry = sut.localState(identity).journal.single() + assertEquals(AUTOMATIC_ATTEMPT_ID, entry.attemptId) + assertEquals(Stage.SENT, entry.stage) + assertTrue(entry.isAutomatic) + assertEquals(request.id, entry.requestId) + assertEquals(ALLOWANCE_ID, entry.allowanceId) + assertEquals(1_000uL, entry.amountSats) + assertEquals(PAYMENT_HASH, entry.paymentHash) + assertEquals(fixtures.lightningIdentifier, entry.paymentEndpointIdentifier) + assertFalse(sut.isHandling(request.id)) + } + + @Test + fun `automatic lightning payment caps routing fees at one percent of the request`() = test { + sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + // A 1 000 sat request may spend at most 10 sat (10 000 msat) on routing fees. + verify(payer).payLightning(eq(INVOICE), isNull(), anyOrNull(), eq(10_000uL)) + } + + @Test + fun `node settlement that arrives before the send call returns is kept`() = test { + sut.activate(identity) + whenever(payer.payLightning(any(), anyOrNull(), anyOrNull(), any())).doSuspendableAnswer { + sut.lightningPaymentSettled(PAYMENT_HASH, succeeded = true) + Result.success(PAYMENT_HASH) + } + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.STARTED, result) + assertEquals(Stage.SUCCEEDED, sut.localState(identity).journal.single().stage) + assertEquals(listOf(PaymentOutcomeReport(AUTOMATIC_ATTEMPT_ID, PaymentOutcome.SUCCEEDED)), recordedOutcomes) + } + + @Test + fun `amount-less invoice is paid exactly the requested amount`() = test { + whenever(payer.resolve(any(), any())) + .thenReturn(Result.success(lightningPayment().copy(lightningInvoiceHasAmount = false))) + + sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + verify(payer).payLightning(eq(INVOICE), eq(1_000uL), anyOrNull(), any()) + } + + @Test + fun `request waits without acceptance while the payee's payment list is pending`() = test { + whenever(payer.resolve(any(), any())).thenReturn(Result.failure(PaykitAllowanceError.PaymentListPending)) + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.DEFERRED, result) + verify(sdk, never()).acceptPaymentRequestAutomatically(any(), any(), any()) + } + + @Test + fun `blocked candidate stays manual without acceptance`() = test { + candidates = listOf(candidate(blocked = AllowanceAccountingBlock.SharedRule("amount_outside_range"))) + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) + verify(sdk).evaluateAllowanceCandidates(any(), any()) + verify(sdk, never()).acceptPaymentRequestAutomatically(any(), any(), any()) + verify(sdk, never()).reserveAutomaticPayment(any(), any(), any()) + verifyNoInteractions(payer) + } + + @Test + fun `allowance without per-payment and monthly limits never pays automatically`() = test { + val request = fixtures.paymentRequest(amountSats = 50_000_000uL) + val unbounded = fixtures.allowance(perPaymentMaxSats = null, monthlyLimitSats = null, monthlyAnchor = null) + + val result = sut.autoPay(request, listOf(unbounded), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) + verify(payer, never()).payLightning(any(), anyOrNull(), anyOrNull(), any()) + } + + @Test + fun `automatic lightning payment carries the request's submission deadline`() = test { + val request = fixtures.paymentRequest() + + sut.autoPay(request, listOf(fixtures.allowance()), identity) + + verify(payer).payLightning(any(), anyOrNull(), eq(request.submissionDeadline(null)), any()) + } + + @Test + fun `request above the per-payment limit stays manual and notifies once`() = test { + collectEvents() + candidates = listOf(candidate(blocked = AllowanceAccountingBlock.SharedRule("amount_outside_range"))) + val request = fixtures.paymentRequest() + val allowances = listOf(fixtures.allowance(perPaymentMaxSats = 500uL)) + + val first = sut.autoPay(request, allowances, identity) + val second = sut.autoPay(request, allowances, identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, first) + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, second) + verify(sdk, never()).acceptPaymentRequestAutomatically(any(), any(), any()) + verifyNoInteractions(payer) + assertEquals( + listOf(PaykitAllowanceEvent.LimitReached(fixtures.counterpartyKey, 1_000uL)), + events + ) + } + + @Test + fun `over the monthly cap stays manual and notifies once`() = test { + collectEvents() + accountingState = stateNearTheMonthlyCap() + val request = fixtures.paymentRequest() + + val first = sut.autoPay(request, listOf(fixtures.allowance()), identity) + val second = sut.autoPay(request, listOf(fixtures.allowance()), identity) + val limitReached: PaykitAllowanceEvent = PaykitAllowanceEvent.LimitReached(fixtures.counterpartyKey, 1_000uL) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, first) + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, second) + verify(sdk, never()).acceptPaymentRequestAutomatically(any(), any(), any()) + verify(sdk, never()).reserveAutomaticPayment(any(), any(), any()) + verifyNoInteractions(payer) + assertEquals(listOf(limitReached), events) + } + + @Test + fun `another contact's payments under the same allowance id never fill this contact's cap`() = test { + collectEvents() + val otherScope = PaymentAccountingScope( + localPublicKey = fixtures.identityKey, + counterparty = fixtures.otherCounterpartyKey, + paymentRequestId = "other-paid", + ) + accountingState = fixtures.accountingState( + occurrences = listOf( + PaymentOccurrenceRecord( + key = PaymentOccurrenceKey(request = otherScope, billingPeriod = null), + disposition = PaymentDisposition.Automatic, + allowanceId = ALLOWANCE_ID, + associationRevision = 1uL, + attempts = listOf(paidAttempt("other-paid", "0.000495", "2026-09-05T10:00:00Z")), + ), + ), + ) + val allowances = listOf( + fixtures.allowance(counterparty = fixtures.otherCounterpartyKey), + fixtures.allowance(), + ) + + val result = sut.autoPay(fixtures.paymentRequest(), allowances, identity) + + assertEquals(PaykitAllowanceAutoPayResult.STARTED, result) + assertTrue(events.none { it is PaykitAllowanceEvent.LimitReached }) + } + + @Test + fun `execution claim held by another app keeps the request manual before any acceptance`() = test { + whenever(sdk.claimPaymentRequestForExecution(any(), any())).doSuspendableAnswer { + error("Another app owns the execution claim") + } + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) + verify(sdk, never()).acceptPaymentRequestAutomatically(any(), any(), any()) + verify(sdk, never()).reserveAutomaticPayment(any(), any(), any()) + verify(payer, never()).payLightning(any(), anyOrNull(), anyOrNull(), any()) + } + + @Test + fun `blocked reservation marks the payment manual only`() = test { + collectEvents() + automaticReservation = PaymentAttemptDecision.Blocked( + AllowanceAccountingBlock.SharedRule("period_amount_limit"), + ) + val request = fixtures.paymentRequest() + + val result = sut.autoPay(request, listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) + val limitReached: PaykitAllowanceEvent = PaykitAllowanceEvent.LimitReached(fixtures.counterpartyKey, 1_000uL) + val scope = PaymentRequestScope(request.counterparty, request.paymentRequestId) + verify(sdk).markPaymentManualOnly(PaymentOccurrence(scope, billingPeriod = null)) + verify(sdk, never()).beginPaymentExecution(any(), any()) + verify(payer, never()).payLightning(any(), anyOrNull(), anyOrNull(), any()) + assertEquals(emptyList(), sut.localState(identity).journal) + assertEquals(listOf(limitReached), events) + } + + @Test + fun `a bitcoin request that also offers a USDT rail is paid automatically`() = test { + val request = fixtures.paymentRequest().copy( + pricing = PaykitRequestPricing(PaymentConversion.Fixed(listOf(ConversionRate("usdt-arbitrum", "100000")))), + ) + + val result = sut.autoPay(request, listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.STARTED, result) + verify(payer).payLightning(any(), anyOrNull(), anyOrNull(), any()) + } + + @Test + fun `a reservation blocked for another reason keeps the request manual without Limit Reached`() = test { + collectEvents() + automaticReservation = PaymentAttemptDecision.Blocked(AllowanceAccountingBlock.SharedRule("not_active")) + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) + assertTrue(events.none { it is PaykitAllowanceEvent.LimitReached }) + } + + @Test + fun `a failing begin call releases the reservation before anything is sent`() = test { + whenever(sdk.beginPaymentExecution(any(), any())).doSuspendableAnswer { throw TestAllowanceError("begin") } + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) + assertEquals(listOf(PaymentOutcomeReport(AUTOMATIC_ATTEMPT_ID, PaymentOutcome.FAILED)), recordedOutcomes) + verify(payer, never()).consumePaymentList(any(), any()) + verify(payer, never()).payLightning(any(), anyOrNull(), anyOrNull(), any()) + } + + @Test + fun `blocked begin records a failed outcome`() = test { + beginDecision = PaymentAttemptDecision.Blocked(AllowanceAccountingBlock.ManualOnly) + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) + assertEquals(listOf(PaymentOutcomeReport(AUTOMATIC_ATTEMPT_ID, PaymentOutcome.FAILED)), recordedOutcomes) + assertEquals(listOf(Stage.FAILED), sut.localState(identity).journal.map { it.stage }) + verify(payer, never()).consumePaymentList(any(), any()) + verify(payer, never()).prepareProof(any(), any(), any(), anyOrNull()) + verify(payer, never()).payLightning(any(), anyOrNull(), anyOrNull(), any()) + } + + @Test + fun `failed proof preparation releases the attempt before any payment`() = test { + whenever(payer.prepareProof(any(), any(), any(), anyOrNull())) + .thenReturn(Result.failure(PaykitPaymentRequestError.RequestUnavailable)) + val request = fixtures.paymentRequest() + + val result = sut.autoPay(request, listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) + verify(payer).cancelProofPreparation(request) + verify(payer).releasePaymentList(eq(request.counterparty), any()) + assertEquals(listOf(PaymentOutcomeReport(AUTOMATIC_ATTEMPT_ID, PaymentOutcome.FAILED)), recordedOutcomes) + verify(payer, never()).payLightning(any(), anyOrNull(), anyOrNull(), any()) + } + + @Test + fun `unsaved journal stops the attempt before anything is consumed`() = test { + failSave = { true } + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) + assertEquals(listOf(PaymentOutcomeReport(AUTOMATIC_ATTEMPT_ID, PaymentOutcome.FAILED)), recordedOutcomes) + verify(sdk, never()).beginPaymentExecution(any(), any()) + verify(payer, never()).consumePaymentList(any(), any()) + verify(payer, never()).payLightning(any(), anyOrNull(), anyOrNull(), any()) + } + + @Test + fun `unsaved sending stage releases the payment list and never sends`() = test { + failSave = { "SENDING" in it } + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) + assertEquals(listOf(PaymentOutcomeReport(AUTOMATIC_ATTEMPT_ID, PaymentOutcome.FAILED)), recordedOutcomes) + verify(payer).discardLightningProof(PAYMENT_HASH) + verify(payer).releasePaymentList(any(), any()) + verify(payer, never()).payLightning(any(), anyOrNull(), anyOrNull(), any()) + } + + @Test + fun `lightning send failure releases only a payment that never left`() = test { + whenever( + payer.payLightning(any(), anyOrNull(), anyOrNull(), any()) + ).thenReturn(Result.failure(TestAllowanceError("send"))) + whenever(payer.failLightningPayment(any(), any())).thenReturn(true, false) + + val first = sut.autoPay(fixtures.paymentRequest(id = "request-1"), listOf(fixtures.allowance()), identity) + val second = sut.autoPay(fixtures.paymentRequest(id = "request-2"), listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, first) + // An uncertain send stays with the node, whose payment event records the outcome. + assertEquals(PaykitAllowanceAutoPayResult.STARTED, second) + assertEquals( + listOf(PaymentOutcome.FAILED, PaymentOutcome.UNKNOWN), + recordedOutcomes.map { it.outcome }, + ) + assertEquals(Stage.UNKNOWN, sut.localState(identity).journal.single().stage) + } + + @Test + fun `a payment list given back by a failed send is not given back again by the node's failure event`() = test { + sut.activate(identity) + whenever( + payer.payLightning(any(), anyOrNull(), anyOrNull(), any()) + ).thenReturn(Result.failure(TestAllowanceError("send"))) + whenever(payer.failLightningPayment(any(), any())).thenReturn(true) + + sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + sut.lightningPaymentSettled(PAYMENT_HASH, succeeded = false) + + verify(payer, times(1)).releasePaymentList(any(), any()) + assertNull(sut.localState(identity).journal.single().paymentListVersion) + } + + @Test + fun `a payment list the node's failure event gave back during the send is not given back again`() = test { + sut.activate(identity) + whenever(payer.payLightning(any(), anyOrNull(), anyOrNull(), any())).doSuspendableAnswer { + sut.lightningPaymentSettled(PAYMENT_HASH, succeeded = false) + Result.failure(TestAllowanceError("send")) + } + whenever(payer.failLightningPayment(any(), any())).thenReturn(true) + + sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + verify(payer, times(1)).releasePaymentList(any(), any()) + } + + @Test + fun `payee offering only an on-chain endpoint stays manual before any acceptance`() = test { + whenever(payer.resolve(any(), any())).thenReturn(Result.success(onchainPayment())) + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) + verify(sdk, never()).acceptPaymentRequestAutomatically(any(), any(), any()) + verify(sdk, never()).reserveAutomaticPayment(any(), any(), any()) + verify(payer, never()).consumePaymentList(any(), any()) + } + + @Test + fun `request in another asset stays manual`() = test { + val request = fixtures.paymentRequest().copy(amount = PaykitAmount(PaykitAsset.USDT, 1_000_000uL)) + + val result = sut.autoPay(request, listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) + verify(sdk, never()).evaluateAllowanceCandidates(any(), any()) + } + + @Test + fun `lightning settlement records success for the journaled attempt`() = test { + collectEvents() + val request = fixtures.paymentRequest() + seedJournal(journalEntry("attempt-1", request, Stage.SENT, paymentHash = PAYMENT_HASH)) + sut.activate(identity) + + sut.lightningPaymentSettled("f".repeat(64), succeeded = true) + assertEquals(emptyList(), recordedOutcomes) + + sut.lightningPaymentSettled(PAYMENT_HASH.uppercase(), succeeded = true) + sut.lightningPaymentSettled(PAYMENT_HASH, succeeded = true) + + assertEquals(listOf(PaymentOutcomeReport("attempt-1", PaymentOutcome.SUCCEEDED)), recordedOutcomes) + assertEquals(listOf(Stage.SUCCEEDED), sut.localState(identity).journal.map { it.stage }) + assertEquals(listOf("attempt-1"), sut.succeededAutomaticPayments(identity).map { it.attemptId }) + assertEquals( + listOf(PaykitAllowanceEvent.PaidAutomatically(fixtures.counterpartyKey, 1_000uL, PAYMENT_HASH)), + events.filterIsInstance(), + ) + verify(payer, never()).payLightning(any(), anyOrNull(), anyOrNull(), any()) + } + + @Test + fun `a failed automatic lightning payment gives the payee's payment list back`() = test { + sut.activate(identity) + val request = fixtures.paymentRequest() + seedJournal( + journalEntry("attempt-1", request, Stage.SENT, paymentHash = PAYMENT_HASH) + .copy(paymentListVersion = 4uL, previousPaymentListVersion = 3uL), + ) + + sut.lightningPaymentSettled(PAYMENT_HASH, succeeded = false) + + assertEquals(listOf(PaymentOutcomeReport("attempt-1", PaymentOutcome.FAILED)), recordedOutcomes) + verify(payer).releasePaymentList( + eq(request.counterparty), + eq(PrivatePaykitPaymentContext(emptyMap(), paymentListVersion = 4uL, previousPaymentListVersion = 3uL)), + ) + assertTrue(sut.isAutomaticPayment(PAYMENT_HASH.uppercase())) + assertFalse(sut.isAutomaticPayment("f".repeat(64))) + } + + @Test + fun `a later manual retry of the same invoice is not an automatic payment`() = test { + sut.activate(identity) + val request = fixtures.paymentRequest() + seedJournal( + journalEntry("automatic", request, Stage.FAILED, paymentHash = PAYMENT_HASH), + journalEntry("manual", request, Stage.SENT, paymentHash = PAYMENT_HASH).copy(isAutomatic = false), + ) + + assertFalse(sut.isAutomaticPayment(PAYMENT_HASH)) + } + + @Test + fun `automatic attempts journal the payment list they consume`() = test { + sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + val entry = sut.localState(identity).journal.single() + assertEquals(lightningPayment().context.paymentListVersion, entry.paymentListVersion) + } + + @Test + fun `open lightning attempts settle from the node's payment status`() = test { + val paidHash = "1".repeat(64) + val pendingHash = "2".repeat(64) + val request = fixtures.paymentRequest() + seedJournal( + journalEntry("paid", request, Stage.UNKNOWN, paymentHash = paidHash), + journalEntry("pending", request, Stage.SENT, paymentHash = pendingHash), + ) + nodeStatuses[paidHash] = PaymentStatus.SUCCEEDED + nodeStatuses[pendingHash] = PaymentStatus.PENDING + sut.activate(identity) + + sut.settleOpenLightningAttempts() + + assertEquals(listOf(PaymentOutcomeReport("paid", PaymentOutcome.SUCCEEDED)), recordedOutcomes) + } + + @Test + fun `recovery reports an automatic payment that settled while the app was closed`() = test { + collectEvents() + val request = fixtures.paymentRequest() + accountingState = fixtures.accountingState( + occurrences = listOf(occurrence("sent-paid", PaymentExecutionStatus.SUBMITTED)), + ) + seedJournal(journalEntry("sent-paid", request, Stage.SENT, paymentHash = PAYMENT_HASH)) + nodeStatuses[PAYMENT_HASH] = PaymentStatus.SUCCEEDED + + sut.recover(identity) + + assertEquals( + listOf(PaykitAllowanceEvent.PaidAutomatically(fixtures.counterpartyKey, 1_000uL, PAYMENT_HASH)), + events.filterIsInstance(), + ) + } + + @Test + fun `recovery gives the payment list back when it fails an automatic attempt`() = test { + val request = fixtures.paymentRequest() + accountingState = fixtures.accountingState( + occurrences = listOf(occurrence("consumed", PaymentExecutionStatus.SUBMITTED)), + ) + seedJournal(journalEntry("consumed", request, Stage.SUBMITTED).copy(paymentListVersion = 4uL)) + + sut.recover(identity) + + assertEquals(listOf(PaymentOutcomeReport("consumed", PaymentOutcome.FAILED)), recordedOutcomes) + verify(payer).releasePaymentList( + eq(request.counterparty), + eq(PrivatePaykitPaymentContext(emptyMap(), paymentListVersion = 4uL)), + ) + } + + @Test + fun `recovery syncs an attempt the ledger already settled and announces it once`() = test { + collectEvents() + val request = fixtures.paymentRequest() + accountingState = fixtures.accountingState( + occurrences = listOf(occurrence("settled", PaymentExecutionStatus.SUCCEEDED)), + ) + seedJournal(journalEntry("settled", request, Stage.SENT, paymentHash = PAYMENT_HASH)) + sut.activate(identity) + + sut.recover(identity) + + // Recovery alone settles it, before any node event arrives. + val paid = PaykitAllowanceEvent.PaidAutomatically(fixtures.counterpartyKey, 1_000uL, PAYMENT_HASH) + assertEquals(listOf(Stage.SUCCEEDED), sut.localState(identity).journal.map { it.stage }) + assertEquals(listOf(paid), events.filterIsInstance()) + + sut.lightningPaymentSettled(PAYMENT_HASH, succeeded = true) + + assertEquals(listOf(paid), events.filterIsInstance()) + } + + @Test + fun `recovery syncs an attempt the ledger already failed and gives its payment list back`() = test { + val request = fixtures.paymentRequest() + accountingState = fixtures.accountingState( + occurrences = listOf(occurrence("failed", PaymentExecutionStatus.FAILED)), + ) + seedJournal( + journalEntry("failed", request, Stage.SENT, paymentHash = PAYMENT_HASH).copy(paymentListVersion = 5uL), + ) + + sut.recover(identity) + + assertEquals(listOf(Stage.FAILED), sut.localState(identity).journal.map { it.stage }) + verify(payer).releasePaymentList( + eq(request.counterparty), + eq(PrivatePaykitPaymentContext(emptyMap(), paymentListVersion = 5uL)), + ) + } + + @Test + fun `local capacity check flags only requests that fit no allowance`() = test { + collectEvents() + accountingState = stateNearTheMonthlyCap() + + assertTrue(sut.classifyAboveLimit(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity)) + assertFalse( + sut.classifyAboveLimit(fixtures.paymentRequest(amountSats = 1uL), listOf(fixtures.allowance()), identity) + ) + assertFalse(sut.classifyAboveLimit(fixtures.paymentRequest(), emptyList(), identity)) + assertEquals(1, events.count { it is PaykitAllowanceEvent.LimitReached }) + } + + @Test + fun `recovery resolves open attempts without paying again`() = test { + val request = fixtures.paymentRequest() + val paidHash = "1".repeat(64) + val pendingHash = "2".repeat(64) + accountingState = fixtures.accountingState( + revision = 4uL, + occurrences = listOf( + occurrence("prepared", PaymentExecutionStatus.PREPARED), + occurrence("old-epoch", PaymentExecutionStatus.PREPARED, epoch = "epoch-0"), + occurrence("never-sent", PaymentExecutionStatus.SUBMITTED), + occurrence("sent-paid", PaymentExecutionStatus.SUBMITTED), + occurrence("sent-pending", PaymentExecutionStatus.SUBMITTED), + occurrence("done", PaymentExecutionStatus.SUCCEEDED), + ), + ) + seedJournal( + journalEntry("prepared", request, Stage.PREPARED), + journalEntry("never-sent", request, Stage.SUBMITTED), + journalEntry("sent-paid", request, Stage.SENT, paymentHash = paidHash), + journalEntry("sent-pending", request, Stage.SENT, paymentHash = pendingHash), + ) + nodeStatuses[paidHash] = PaymentStatus.SUCCEEDED + nodeStatuses[pendingHash] = PaymentStatus.PENDING + + sut.recover(identity) + + val expected = mapOf( + "prepared" to PaymentOutcome.FAILED, + "never-sent" to PaymentOutcome.FAILED, + "sent-paid" to PaymentOutcome.SUCCEEDED, + "sent-pending" to PaymentOutcome.UNKNOWN, + ) + assertEquals(expected, recordedOutcomes.associate { it.attemptId to it.outcome }) + assertEquals(4, recordedOutcomes.size) + assertEquals( + mapOf( + "prepared" to Stage.FAILED, + "never-sent" to Stage.FAILED, + "sent-paid" to Stage.SUCCEEDED, + "sent-pending" to Stage.UNKNOWN, + ), + sut.localState(identity).journal.associate { it.attemptId to it.stage }, + ) + verify(payer, never()).payLightning(any(), anyOrNull(), anyOrNull(), any()) + verify(payer, never()).resolve(any(), any()) + assertEquals(emptyList(), reconciliations) + } + + @Test + fun `recovery settles an on-chain attempt only on the wallet's positive evidence`() = test { + val request = fixtures.paymentRequest() + val refusedTxid = "a".repeat(64) + val acceptedTxid = "b".repeat(64) + accountingState = fixtures.accountingState( + occurrences = listOf( + occurrence("onchain-refused", PaymentExecutionStatus.UNKNOWN), + occurrence("onchain-accepted", PaymentExecutionStatus.UNKNOWN), + ), + ) + seedJournal( + journalEntry("onchain-refused", request, Stage.UNKNOWN).copy(transactionId = refusedTxid), + journalEntry("onchain-accepted", request, Stage.UNKNOWN).copy(transactionId = acceptedTxid), + ) + whenever(payer.onchainPaymentOutcome(request.id, refusedTxid)).thenReturn(null) + whenever(payer.onchainPaymentOutcome(request.id, acceptedTxid)).thenReturn(PaymentOutcome.SUCCEEDED) + + sut.recover(identity) + + assertEquals( + listOf(PaymentOutcomeReport("onchain-accepted", PaymentOutcome.SUCCEEDED)), + recordedOutcomes, + ) + assertEquals( + mapOf("onchain-refused" to Stage.UNKNOWN, "onchain-accepted" to Stage.SUCCEEDED), + sut.localState(identity).journal.associate { it.attemptId to it.stage }, + ) + } + + @Test + fun `recovery leaves a wallet without a ledger untouched`() = test { + accountingState = null + + sut.recover(identity) + + verify(sdk).allowanceAccountingState() + verify(sdk, never()).reconcileAllowanceAccounting(any()) + verify(sdk, never()).recordPaymentOutcome(any()) + } + + @Test + fun `recovery leaves a manual payment in progress alone`() = test { + sut.activate(identity) + val attemptId = sut.beginManualPayment(fixtures.paymentRequest(), fixtures.lightningIdentifier).getOrThrow() + accountingState = fixtures.accountingState( + occurrences = listOf(occurrence(MANUAL_ATTEMPT_ID, PaymentExecutionStatus.SUBMITTED)), + ) + + sut.recover(identity) + assertEquals(emptyList(), recordedOutcomes) + + sut.finishManualPayment(checkNotNull(attemptId), PaymentOutcome.SUCCEEDED) + assertEquals(listOf(PaymentOutcomeReport(MANUAL_ATTEMPT_ID, PaymentOutcome.SUCCEEDED)), recordedOutcomes) + } + + // endregion + + // region Manual payments + + @Test + fun `recovery never fails a manual on-chain payment that may have left before the kill`() = test { + sut.activate(identity) + val attemptId = + checkNotNull(sut.beginManualPayment(fixtures.paymentRequest(), fixtures.onchainIdentifier).getOrThrow()) + accountingState = fixtures.accountingState( + occurrences = listOf(occurrence(attemptId, PaymentExecutionStatus.SUBMITTED)), + ) + val restarted = PaykitAllowanceExecutor( + ioDispatcher = testDispatcher, + paykitSdkService = sdk, + store = PaykitAllowanceStore(keychain), + payer = payer, + clock = clock, + accountingAmount = { _, _ -> fixtures.accountingAmount("0.00001") }, + ) + + restarted.recover(identity) + + assertEquals( + emptyList(), + recordedOutcomes.filter { it.attemptId == attemptId && it.outcome == PaymentOutcome.FAILED }, + ) + } + + @Test + fun `manual payment fails when the request is already recorded`() = test { + sut.activate(identity) + manualReservation = PaymentAttemptDecision.Blocked(AllowanceAccountingBlock.PaymentAlreadyRecorded) + + val result = sut.beginManualPayment(fixtures.paymentRequest(), fixtures.lightningIdentifier) + + assertIs(result.exceptionOrNull()) + verify(sdk, never()).beginPaymentExecution(any(), any()) + } + + @Test + fun `manual payment is journaled and submitted when ready`() = test { + sut.activate(identity) + val request = fixtures.paymentRequest() + + val attemptId = sut.beginManualPayment(request, fixtures.lightningIdentifier).getOrThrow() + + assertEquals(MANUAL_ATTEMPT_ID, attemptId) + val entry = sut.localState(identity).journal.single() + assertEquals(Stage.SUBMITTED, entry.stage) + assertFalse(entry.isAutomatic) + assertNull(entry.allowanceId) + + sut.manualLightningPaymentSent(MANUAL_ATTEMPT_ID, PAYMENT_HASH.uppercase()) + assertEquals(PAYMENT_HASH, sut.localState(identity).journal.single().paymentHash) + assertEquals(Stage.SENT, sut.localState(identity).journal.single().stage) + + manualReservation = PaymentAttemptDecision.Blocked(AllowanceAccountingBlock.ManualOnly) + assertNull(sut.beginManualPayment(request, fixtures.lightningIdentifier).getOrThrow()) + } + + @Test + fun `manual lightning attempt settled by the node is recorded once and never counts as automatic`() = test { + collectEvents() + sut.activate(identity) + val attemptId = checkNotNull( + sut.beginManualPayment(fixtures.paymentRequest(), fixtures.lightningIdentifier).getOrThrow(), + ) + sut.manualLightningPaymentSent(attemptId, PAYMENT_HASH) + + sut.lightningPaymentSettled(PAYMENT_HASH, succeeded = true) + sut.finishManualPayment(attemptId, PaymentOutcome.SUCCEEDED) + + assertEquals(listOf(PaymentOutcomeReport(MANUAL_ATTEMPT_ID, PaymentOutcome.SUCCEEDED)), recordedOutcomes) + assertEquals(emptyList(), events.filterIsInstance()) + assertEquals(emptyList(), sut.succeededAutomaticPayments(identity)) + } + + @Test + fun `manual payment is not reported without an identity or for an outgoing request`() = test { + assertNull(sut.beginManualPayment(fixtures.paymentRequest(), fixtures.lightningIdentifier).getOrThrow()) + + sut.activate(identity) + val outgoing = fixtures.paymentRequest().copy(direction = PaykitPaymentRequestDirection.Outgoing) + assertNull(sut.beginManualPayment(outgoing, fixtures.lightningIdentifier).getOrThrow()) + verifyNoInteractions(sdk) + } + + // endregion + + // region Trusted time and reconciliation + + @Test + fun `trusted time never moves backwards`() = test { + val start = fixtures.now + + val first = sut.trustedTime(identity) + now = start - 1.hours + val afterClockMovedBack = sut.trustedTime(identity) + now = start + 1.minutes + val afterClockMovedForward = sut.trustedTime(identity) + + assertEquals(PaykitAllowanceTime.format(start), first) + assertEquals(PaykitAllowanceTime.format(start), afterClockMovedBack) + assertEquals(PaykitAllowanceTime.format(start + 1.minutes), afterClockMovedForward) + assertEquals((start + 1.minutes).toEpochMilliseconds(), sut.localState(identity).lastTrustedTimeMillis) + } + + @Test + fun `missing ledger is reconciled with an empty history`() = test { + accountingState = null + + val reconciled = sut.ensureReconciled(identity) + sut.ensureReconciled(identity) + + val reconciliation = reconciliations.single() + assertNull(reconciliation.expectedRevision) + assertTrue(reconciliation.history.associations.isEmpty()) + assertTrue(reconciliation.history.occurrences.isEmpty()) + assertTrue(reconciliation.history.watermarks.isEmpty()) + assertEquals(emptyList(), reconciliation.outcomes) + assertEquals(PaykitAllowanceTime.format(fixtures.now), reconciliation.trustedTime) + assertFalse(reconciled.requiresReconciliation) + } + + @Test + fun `ledger that requires reconciliation is reconciled at its revision`() = test { + val request = fixtures.paymentRequest() + val paidHash = "3".repeat(64) + accountingState = fixtures.accountingState( + revision = 7uL, + requiresReconciliation = true, + occurrences = listOf( + occurrence("prepared", PaymentExecutionStatus.PREPARED), + occurrence("sent-paid", PaymentExecutionStatus.SUBMITTED), + ), + ) + seedJournal(journalEntry("sent-paid", request, Stage.SENT, paymentHash = paidHash)) + nodeStatuses[paidHash] = PaymentStatus.SUCCEEDED + + sut.ensureReconciled(identity) + + val reconciliation = reconciliations.single() + assertEquals(7uL, reconciliation.expectedRevision) + assertEquals( + listOf("prepared", "sent-paid"), + reconciliation.history.occurrences.flatMap { it.attempts }.map { it.attemptId }, + ) + assertEquals( + listOf( + PaymentOutcomeReport("prepared", PaymentOutcome.FAILED), + PaymentOutcomeReport("sent-paid", PaymentOutcome.SUCCEEDED), + ), + reconciliation.outcomes, + ) + verify(payer, never()).payLightning(any(), anyOrNull(), anyOrNull(), any()) + } + + @Test + fun `admission uses the injected clock for the monthly window`() = test { + accountingState = stateNearTheMonthlyCap() + now = Instant.parse("2026-10-02T12:00:00Z") + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.STARTED, result, "September's attempts must not count in October") + assertEquals(listOf(PaykitAllowanceTime.format(now)), evaluatedTrustedTimes) + } + + @Test + fun `admission ignores the subscription clock offset`() = test { + offsetSubscriptionClock(days = 400) + accountingState = stateNearTheMonthlyCap() + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertEquals( + PaykitAllowanceAutoPayResult.MANUAL, + result, + "September's attempts must count; the offset would move the window a year ahead", + ) + assertEquals(listOf(PaykitAllowanceTime.format(fixtures.now)), evaluatedTrustedTimes) + verify(sdk, never()).acceptPaymentRequestAutomatically(any(), any(), any()) + } + + // endregion + + // region Helpers + + private fun offsetSubscriptionClock(days: Int) { + SubscriptionClockOffset.setOffsetDays(days) + assumeTrue( + "The subscription clock offset is unavailable in this build", + SubscriptionClockOffset.offsetDays == days, + ) + } + + @After + fun resetSubscriptionClockOffset() { + SubscriptionClockOffset.setOffsetDays(0) + } + + private fun TestScope.collectEvents() { + backgroundScope.launch { sut.events.collect { events += it } } + } + + private fun candidate(blocked: AllowanceAccountingBlock? = null) = AllowanceCandidate( + allowanceId = ALLOWANCE_ID, + eligiblePaymentEndpointIdentifiers = listOf(PaykitAllowanceFixtures.lightningIdentifier), + blocked = blocked, + ) + + private fun lightningPayment() = PrivatePaykitAllowancePayment( + endpoint = Endpoint( + methodId = MethodId.Bolt11, + value = INVOICE, + rawPayload = """{"value":"$INVOICE"}""", + ), + appId = PAYMENT_APP_ID, + context = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to PAYMENT_APP_ID), 3uL), + lightningPaymentHash = PAYMENT_HASH, + lightningInvoiceHasAmount = true, + ) + + private fun onchainPayment() = PrivatePaykitAllowancePayment( + endpoint = Endpoint( + methodId = MethodId.P2wpkh, + value = ONCHAIN_ADDRESS, + rawPayload = """{"value":"$ONCHAIN_ADDRESS"}""", + ), + appId = PAYMENT_APP_ID, + context = PrivatePaykitPaymentContext(mapOf(MethodId.P2wpkh.rawValue to PAYMENT_APP_ID), 3uL), + lightningPaymentHash = null, + lightningInvoiceHasAmount = false, + ) + + /** Three succeeded automatic payments this month total 49,500 sats of the 50,000 sat cap. */ + private fun stateNearTheMonthlyCap() = fixtures.accountingState( + occurrences = listOf( + fixtures.occurrence( + "paid-1", + listOf(paidAttempt("paid-1", "0.0002", "2026-09-05T10:00:00Z")), + ), + fixtures.occurrence( + "paid-2", + listOf(paidAttempt("paid-2", "0.0002", "2026-09-12T10:00:00Z")), + ), + fixtures.occurrence( + "paid-3", + listOf(paidAttempt("paid-3", "0.000095", "2026-09-20T10:00:00Z")), + ), + ), + ) + + private fun paidAttempt(id: String, amount: String, admittedAt: String) = fixtures.attemptRecord( + id = id, + amount = amount, + admittedAt = admittedAt, + status = PaymentExecutionStatus.SUCCEEDED, + ) + + private fun occurrence( + attemptId: String, + status: PaymentExecutionStatus, + epoch: String = "epoch-1", + ) = fixtures.occurrence( + requestId = "req-$attemptId", + attempts = listOf(fixtures.attemptRecord(id = attemptId, status = status, epoch = epoch)), + ) + + private fun journalEntry( + attemptId: String, + request: PaykitPaymentRequest, + stage: Stage, + paymentHash: String? = null, + ) = JournalEntry( + attemptId = attemptId, + isAutomatic = true, + requestId = request.id, + allowanceId = ALLOWANCE_ID, + amountSats = request.amount.atomic, + paymentEndpointIdentifier = fixtures.lightningIdentifier, + paymentHash = paymentHash, + stage = stage, + createdAtMillis = fixtures.now.toEpochMilliseconds(), + ) + + private suspend fun seedJournal(vararg entries: JournalEntry) { + sut.updateLocalState(identity) { it.copy(journal = entries.toList()) } + } + + // endregion +} + +private class TestAllowanceError(message: String) : AppError(message) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowancePayerTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowancePayerTest.kt new file mode 100644 index 0000000000..8e8cc711b9 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowancePayerTest.kt @@ -0,0 +1,76 @@ +package to.bitkit.repositories + +import com.synonym.paykit.PaymentOutcome +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.models.WalletScope +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class PaykitAllowancePayerTest : BaseUnitTest() { + private val paymentProofRepo = mock() + private val lightningRepo = mock() + private val sut = PaykitAllowancePayer(mock(), paymentProofRepo, lightningRepo, mock()) + + private val requestId = PaykitPaymentRequestId(paymentRequestId = "req-1", counterparty = "payee") + private val txid = "ab".repeat(32) + + @Before + fun setUp() = test { + whenever(paymentProofRepo.hasVerifiedOnchainProof(any(), any())).thenReturn(false) + } + + @Test + fun `refused or unknown broadcast stays unresolved`() = test { + for (evidence in listOf(OnchainSendEvidence.Rejected, OnchainSendEvidence.Unknown)) { + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt(evidence)) + + assertNull(sut.onchainPaymentOutcome(requestId, txid)) + } + } + + @Test + fun `accepted or observed attempt for the request succeeds`() = test { + for (evidence in listOf(OnchainSendEvidence.Accepted, OnchainSendEvidence.Observed)) { + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt(evidence)) + + assertEquals(PaymentOutcome.SUCCEEDED, sut.onchainPaymentOutcome(requestId, txid)) + } + } + + @Test + fun `accepted attempt for another request proves nothing`() = test { + whenever(lightningRepo.currentOnchainSendAttempt()) + .thenReturn( + attempt(OnchainSendEvidence.Accepted).copy(requestId = requestId.copy(paymentRequestId = "req-2")) + ) + + assertNull(sut.onchainPaymentOutcome(requestId, txid)) + } + + @Test + fun `verified proof succeeds after the attempt was replaced`() = test { + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(null) + whenever(paymentProofRepo.hasVerifiedOnchainProof(requestId, txid)).thenReturn(true) + + assertEquals(PaymentOutcome.SUCCEEDED, sut.onchainPaymentOutcome(requestId, txid)) + } + + @Test + fun `unreadable attempt and no proof stays unresolved`() = test { + whenever(lightningRepo.currentOnchainSendAttempt()).thenThrow(IllegalStateException("unreadable")) + + assertNull(sut.onchainPaymentOutcome(requestId, txid)) + } + + private fun attempt(evidence: OnchainSendEvidence) = OnchainSendAttempt( + walletId = WalletScope.default, attemptId = "attempt", requestId = requestId, + orderId = null, address = "address", amountSats = 1_000uL, isMaxAmount = false, + feeRateSatsPerVByte = 2uL, isTransfer = false, channelId = null, tags = emptyList(), + txid = txid, evidence = evidence, candidateTxids = listOf(txid), + ) +} diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt new file mode 100644 index 0000000000..f227257140 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt @@ -0,0 +1,630 @@ +package to.bitkit.repositories + +import com.synonym.paykit.AllowanceHistoryStatus +import com.synonym.paykit.AllowanceLifecycleState +import com.synonym.paykit.AllowanceLocalRole +import com.synonym.paykit.AllowanceRecord +import com.synonym.paykit.LinkedPeerRecord +import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.OutboundPrivateSendReport +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.junit.After +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.lightningdevkit.ldknode.ChannelDetails +import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.PaymentFailureReason +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.models.NodeLifecycleState +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.repositories.PaykitAllowanceFixtures.ALLOWANCE_ID +import to.bitkit.repositories.PaykitAllowanceFixtures.SECOND_ALLOWANCE_ID +import to.bitkit.repositories.PaykitAllowanceLocalState.Stage +import to.bitkit.services.PaykitSdkService +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.SubscriptionClockOffset +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant + +class PaykitAllowanceRepoTest : BaseUnitTest() { + companion object { + private val PAYMENT_HASH = "ab".repeat(32) + private val TXID = "c".repeat(64) + } + + private val fixtures = PaykitAllowanceFixtures + private val identity = fixtures.identityKey + private val entryKey = "${PubkyPublicKeyFormat.normalized(fixtures.counterpartyKey)}/$ALLOWANCE_ID" + private val sdk = mock() + private val executor = mock() + private val lightningRepo = mock() + private val activityRepo = mock() + private val executorEvents = MutableSharedFlow(extraBufferCapacity = 8) + private val nodeEvents = MutableSharedFlow(extraBufferCapacity = 8) + private val lightningState = MutableStateFlow(LightningState()) + private val terms = fixtures.terms() + private val clock = object : Clock { + override fun now(): Instant = PaykitAllowanceFixtures.now + } + private val proposedTerms = mutableListOf>>() + private var localState = PaykitAllowanceLocalState() + private var records = listOf() + private lateinit var sut: PaykitAllowanceRepo + + @Before + fun setUp() = test { + whenever(executor.events).thenReturn(executorEvents) + whenever(executor.localState(any())).thenAnswer { localState } + whenever(executor.updateLocalState(any(), any())).doSuspendableAnswer { + val change = it.getArgument<(PaykitAllowanceLocalState) -> PaykitAllowanceLocalState>(1) + localState = change(localState) + localState + } + whenever(executor.autoPay(any(), any(), any())).thenReturn(PaykitAllowanceAutoPayResult.STARTED) + whenever(executor.isHandling(any())).thenReturn(false) + whenever(executor.classifyAboveLimit(any(), any(), any())).thenReturn(false) + whenever(lightningRepo.nodeEvents).thenReturn(nodeEvents) + whenever(lightningRepo.lightningState).thenReturn(lightningState) + whenever(activityRepo.setContact(any(), any(), any(), any())).thenReturn(Result.success(Unit)) + whenever(sdk.listAllowances(any())).thenAnswer { records } + whenever(sdk.linkedPeers()).thenReturn(emptyList()) + whenever(sdk.processOutboundPrivateMessages(any())) + .thenReturn(OutboundPrivateSendReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList())) + + sut = PaykitAllowanceRepo( + ioDispatcher = testDispatcher, + paykitSdkService = sdk, + executor = executor, + lightningRepo = lightningRepo, + activityRepo = activityRepo, + clock = clock, + allowanceTerms = { _, monthAnchor, endpoints -> + proposedTerms += monthAnchor to endpoints + terms + }, + ) + } + + // region Entries + + @Test + fun `entries list one grant per contact with the grant's limits`() = test { + records = listOf( + fixtures.record(allowanceId = ALLOWANCE_ID), + fixtures.record(allowanceId = "allowance-other", counterparty = fixtures.otherCounterpartyKey), + fixtures.record(allowanceId = "allowance-invalid", historyStatus = AllowanceHistoryStatus.INVALID), + ) + localState = PaykitAllowanceLocalState(groups = listOf(group(ALLOWANCE_ID))) + + sut.activate(identity) + + val entries = sut.entries.value + val otherKey = "${PubkyPublicKeyFormat.normalized(fixtures.otherCounterpartyKey)}/allowance-other" + assertEquals(listOf("group-1", otherKey), entries.map { it.id }) + val grant = entries.first() + assertEquals(listOf(ALLOWANCE_ID), grant.allowances.map { it.allowanceId }) + assertEquals(ALLOWANCE_ID, grant.primary.allowanceId) + assertEquals(fixtures.limits, grant.limits) + assertEquals(fixtures.counterpartyKey, grant.counterparty) + assertEquals(PaykitAllowance.Role.ALLOWER, grant.role) + assertEquals(5_000uL, grant.perPaymentMaxSats) + assertEquals(50_000uL, grant.monthlyLimitSats) + assertEquals(PaykitAllowance.Status.ACTIVE, grant.status(fixtures.now)) + assertNull(entries.last().limits) + assertEquals(ALLOWANCE_ID, sut.entry("group-1")?.primary?.allowanceId) + } + + @Test + fun `activation recovers once per identity and deactivation clears entries`() = test { + records = listOf(fixtures.record()) + + sut.activate(identity) + sut.activate(identity) + + verify(executor, times(1)).recover(identity) + verify(executor, times(2)).activate(identity) + assertEquals(1, sut.entries.value.size) + + sut.deactivate() + + verify(executor).activate(null) + assertEquals(emptyList(), sut.entries.value) + assertTrue(sut.refresh().isSuccess) + verify(sdk, times(2)).listAllowances(any()) + } + + @Test + fun `auto-paid sats and requests come from succeeded automatic payments`() = test { + records = listOf(fixtures.record(allowanceId = ALLOWANCE_ID)) + val paid = fixtures.paymentRequest(id = "paid") + val failed = fixtures.paymentRequest(id = "failed") + val secondPaid = fixtures.paymentRequest(id = "second") + localState = PaykitAllowanceLocalState( + groups = listOf(group(ALLOWANCE_ID)), + journal = listOf( + journalEntry("a", paid, ALLOWANCE_ID, 1_000uL, Stage.SUCCEEDED), + journalEntry("b", secondPaid, ALLOWANCE_ID, 2_000uL, Stage.SUCCEEDED), + journalEntry("c", failed, ALLOWANCE_ID, 5_000uL, Stage.FAILED), + journalEntry("d", fixtures.paymentRequest(id = "manual"), null, 7_000uL, Stage.SUCCEEDED, false), + ), + ) + + sut.activate(identity) + + assertEquals(mapOf("group-1" to 3_000uL), sut.autoPaidSats.value) + assertTrue(sut.isAutoPaid(paid.id)) + assertFalse(sut.isAutoPaid(failed.id)) + assertFalse(sut.isAutoPaid(fixtures.paymentRequest(id = "manual").id)) + } + + @Test + fun `auto-paid sats never count another contact's payments under the same allowance id`() = test { + records = listOf( + fixtures.record(), + fixtures.record(counterparty = fixtures.otherCounterpartyKey), + ) + val otherPaid = fixtures.paymentRequest(id = "other-paid", counterparty = fixtures.otherCounterpartyKey) + localState = PaykitAllowanceLocalState( + journal = listOf(journalEntry("a", otherPaid, ALLOWANCE_ID, 1_000uL, Stage.SUCCEEDED)), + ) + + sut.activate(identity) + + val otherKey = "${PubkyPublicKeyFormat.normalized(fixtures.otherCounterpartyKey)}/$ALLOWANCE_ID" + assertEquals(mapOf(entryKey to 0uL, otherKey to 1_000uL), sut.autoPaidSats.value) + } + + // endregion + + // region Coverage + + @Test + fun `coverage needs an active allower allowance for the request's contact identity`() = test { + records = listOf(fixtures.record(terms = fixtures.terms(expiresAt = (fixtures.now + 30.days).toString()))) + + sut.activate(identity) + + assertTrue(sut.coversRequest(fixtures.paymentRequest())) + assertFalse(sut.coversRequest(fixtures.paymentRequest(counterparty = fixtures.otherCounterpartyKey))) + + records = listOf( + fixtures.record(terms = fixtures.terms(expiresAt = "2026-09-20T00:00:00Z")), + fixtures.record(allowanceId = "allowee", localRole = AllowanceLocalRole.ALLOWEE, proposedByMe = false), + fixtures.record(allowanceId = "proposed", state = AllowanceLifecycleState.PROPOSED), + ) + sut.refresh() + + assertFalse(sut.coversRequest(fixtures.paymentRequest())) + } + + @Test + fun `coverage ignores the subscription clock offset`() = test { + SubscriptionClockOffset.setOffsetDays(400) + assumeTrue( + "The subscription clock offset is unavailable in this build", + SubscriptionClockOffset.offsetDays == 400, + ) + records = listOf(fixtures.record(terms = fixtures.terms(expiresAt = (fixtures.now + 30.days).toString()))) + + sut.activate(identity) + + assertTrue(sut.coversRequest(fixtures.paymentRequest()), "The allowance still has 30 days in real time") + assertFalse(sut.coversRequest(fixtures.paymentRequest(counterparty = fixtures.otherCounterpartyKey))) + } + + @After + fun resetSubscriptionClockOffset() { + SubscriptionClockOffset.setOffsetDays(0) + } + + @Test + fun `requests created before the allowance was accepted stay manual`() = test { + records = listOf(fixtures.record(lastEventAt = "2026-09-24T11:30:00Z")) + + sut.activate(identity) + + assertFalse(sut.coversRequest(fixtures.paymentRequest(createdAt = "2026-09-24T11:00:00Z"))) + assertTrue(sut.coversRequest(fixtures.paymentRequest(createdAt = "2026-09-24T11:29:40Z")), "Within tolerance") + assertTrue(sut.coversRequest(fixtures.paymentRequest(createdAt = "2026-09-24T11:45:00Z"))) + } + + // endregion + + // region Lifecycle + + @Test + fun `propose sends the terms to the linked contact and records one entry`() = test { + whenever(sdk.linkedPeers()).thenReturn( + listOf( + linkedPeer(fixtures.otherCounterpartyKey), + linkedPeer(fixtures.counterpartyKey), + ), + ) + whenever(sdk.proposeAllowance(any(), any(), any())).doSuspendableAnswer { + fixtures.record( + allowanceId = ALLOWANCE_ID, + state = AllowanceLifecycleState.PROPOSED, + terms = terms, + ).also { record -> records = records + record } + } + sut.activate(identity) + + val result = sut.propose(fixtures.counterpartyKey, fixtures.limits) + + assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + verify(sdk).proposeAllowance(fixtures.counterpartyKey, AllowanceLocalRole.ALLOWER, terms) + verify(sdk).processOutboundPrivateMessages(fixtures.counterpartyKey) + assertEquals( + listOf(fixtures.septemberAnchor to PaykitAllowanceRepo.allowedPaymentEndpointIdentifiers()), + proposedTerms, + ) + val group = localState.groups.single() + assertEquals(listOf(ALLOWANCE_ID), group.allowanceIds) + assertEquals(fixtures.limits, group.limits) + assertEquals(fixtures.counterpartyKey, group.counterparty) + val entry = sut.entries.value.single() + assertEquals(group.id, entry.id) + assertEquals(fixtures.limits, entry.limits) + assertEquals(PaykitAllowance.Status.AWAITING_ANSWER, entry.status(fixtures.now)) + } + + @Test + fun `propose fails when the contact link is not up`() = test { + whenever(sdk.linkedPeers()) + .thenReturn(listOf(linkedPeer(fixtures.counterpartyKey, LinkedPeerState.LINKING))) + sut.activate(identity) + + val result = sut.propose(fixtures.counterpartyKey, fixtures.limits) + + assertIs(result.exceptionOrNull()) + verify(sdk, never()).proposeAllowance(any(), any(), any()) + assertTrue(localState.groups.isEmpty()) + } + + @Test + fun `an allowance stays one grant for the identity when the contact adds another app later`() = test { + // The grant is bound to the contact's identity, so one that links an app later is covered without a proposal. + records = listOf(fixtures.record(allowanceId = ALLOWANCE_ID)) + localState = PaykitAllowanceLocalState(groups = listOf(group(ALLOWANCE_ID))) + whenever(sdk.linkedPeers()).thenReturn(listOf(linkedPeer(fixtures.counterpartyKey))) + sut.activate(identity) + + sut.refresh() + + verify(sdk, never()).proposeAllowance(any(), any(), any()) + assertTrue(sut.coversRequest(fixtures.paymentRequest())) + assertEquals(listOf(ALLOWANCE_ID), sut.entries.value.single().allowances.map { it.allowanceId }) + } + + @Test + fun `received proposal is presented once and accepting answers it`() = test { + val proposalRecord = receivedProposal() + records = listOf(proposalRecord) + whenever(sdk.acceptAllowance(any(), any(), any())).thenReturn(proposalRecord) + sut.activate(identity) + + val proposal = checkNotNull(sut.proposalForPresentation()) + assertEquals(entryKey, proposal.id) + sut.markProposalPresented(proposal.id) + assertNull(sut.proposalForPresentation()) + assertEquals(setOf(entryKey), localState.presentedProposalIds) + + assertTrue(sut.accept(proposal.id).isSuccess) + + verify(sdk).acceptAllowance(fixtures.counterpartyKey, ALLOWANCE_ID, 1uL) + verify(sdk).processOutboundPrivateMessages(fixtures.counterpartyKey) + } + + @Test + fun `accepting a proposal never answers another contact's proposal with the same id`() = test { + val offer = receivedProposal() + val ask = fixtures.record( + counterparty = fixtures.otherCounterpartyKey, + localRole = AllowanceLocalRole.ALLOWER, + state = AllowanceLifecycleState.PROPOSED, + proposedByMe = false, + lastEventAt = "2026-09-01T10:00:00Z", + ) + records = listOf(offer, ask) + whenever(sdk.acceptAllowance(any(), any(), any())).thenReturn(offer) + sut.activate(identity) + + val presented = checkNotNull(sut.proposalForPresentation()) + assertEquals(PaykitAllowance.Role.ALLOWEE, presented.role) + assertEquals(fixtures.counterpartyKey, presented.counterparty) + assertTrue(sut.accept(presented.id).isSuccess) + + verify(sdk).acceptAllowance(fixtures.counterpartyKey, ALLOWANCE_ID, 1uL) + verify(sdk, never()).acceptAllowance(eq(fixtures.otherCounterpartyKey), any(), any()) + } + + @Test + fun `received proposal to pay without per-payment or monthly limits is never presented or accepted`() = test { + val expiryOnly = fixtures.record( + localRole = AllowanceLocalRole.ALLOWER, + state = AllowanceLifecycleState.PROPOSED, + proposedByMe = false, + terms = fixtures.terms(perPaymentMaximum = null, monthlyLimit = null, expiresAt = "2027-09-24T12:00:00Z"), + ) + records = listOf(expiryOnly) + whenever(sdk.acceptAllowance(any(), any(), any())).thenReturn(expiryOnly) + sut.activate(identity) + + assertNull(sut.proposalForPresentation()) + assertTrue(sut.accept(entryKey).isFailure) + verify(sdk, never()).acceptAllowance(any(), any(), any()) + } + + @Test + fun `received proposal whose per-payment limit cannot be read is never presented`() = test { + val dust = fixtures.record( + localRole = AllowanceLocalRole.ALLOWER, + state = AllowanceLifecycleState.PROPOSED, + proposedByMe = false, + terms = fixtures.terms(perPaymentMaximum = "0.000000001"), + ) + records = listOf(dust) + sut.activate(identity) + + assertNull(sut.proposalForPresentation()) + assertTrue(sut.accept(entryKey).isFailure) + } + + @Test + fun `answering fails when nothing in the entry awaits an answer`() = test { + records = listOf(fixtures.record()) + sut.activate(identity) + + assertIs(sut.reject(entryKey).exceptionOrNull()) + assertIs(sut.accept("missing").exceptionOrNull()) + verify(sdk, never()).rejectAllowance(any(), any()) + } + + @Test + fun `ending an entry ends its allowance and sends the end right away`() = test { + records = listOf(fixtures.record(allowanceId = ALLOWANCE_ID)) + localState = PaykitAllowanceLocalState(groups = listOf(group(ALLOWANCE_ID))) + whenever(sdk.endAllowance(any(), any())).thenReturn(records.last()) + sut.activate(identity) + + assertTrue(sut.end("group-1").isSuccess) + + verify(sdk).endAllowance(fixtures.counterpartyKey, ALLOWANCE_ID) + verify(sdk).processOutboundPrivateMessages(fixtures.counterpartyKey) + } + + // endregion + + // region Automatic payments + + @Test + fun `a coverage update holds until it is released once`() = test { + val first = sut.beginCoverageUpdate() + val second = sut.beginCoverageUpdate() + assertTrue(sut.isUpdating) + + first() + first() + assertTrue(sut.isUpdating) + + second() + assertFalse(sut.isUpdating) + } + + @Test + fun `only a contact this wallet grants an allowance to may be covered`() = test { + records = listOf(fixtures.record(state = AllowanceLifecycleState.PROPOSED)) + sut.activate(identity) + + assertTrue(sut.mayCover(fixtures.paymentRequest())) + assertFalse(sut.mayCover(fixtures.paymentRequest(counterparty = fixtures.otherCounterpartyKey))) + assertFalse(sut.isUpdating) + } + + @Test + fun `a request above the limits is never held back while channels reconnect`() = test { + records = listOf(fixtures.record()) + sut.activate(identity) + val above = fixtures.paymentRequest(id = "above", amountSats = 5_001uL) + val within = fixtures.paymentRequest(id = "within") + whenever(executor.classifyAboveLimit(eq(above), any(), any())).thenReturn(true) + whenever(executor.classifyAboveLimit(eq(within), any(), any())).thenReturn(false) + + assertTrue(sut.processIncomingRequests(listOf(above, within))) + + assertFalse(sut.isAutomaticallyHandling(above)) + assertTrue(sut.isAutomaticallyHandling(within)) + verify(executor, never()).autoPay(any(), any(), any()) + } + + @Test + fun `covered request stays off the Send sheet until it is found manual`() = test { + nodeReady() + records = listOf(fixtures.record()) + whenever(executor.autoPay(any(), any(), any())).thenReturn(PaykitAllowanceAutoPayResult.MANUAL) + sut.activate(identity) + val request = fixtures.paymentRequest() + + assertTrue(sut.isAutomaticallyHandling(request)) + assertFalse(sut.isAutomaticallyHandling(fixtures.paymentRequest(counterparty = fixtures.otherCounterpartyKey))) + + assertFalse(sut.processIncomingRequests(listOf(request))) + assertFalse(sut.isAutomaticallyHandling(request)) + assertFalse(sut.processIncomingRequests(listOf(request))) + verify(executor, times(1)).autoPay(any(), any(), any()) + + records = listOf(fixtures.record(), fixtures.record(allowanceId = SECOND_ALLOWANCE_ID)) + sut.refresh() + sut.processIncomingRequests(listOf(request)) + verify(executor, times(2)).autoPay(any(), any(), any()) + } + + @Test + fun `request being paid stays off the Send sheet`() = test { + sut.activate(identity) + val request = fixtures.paymentRequest() + whenever(executor.isHandling(request.id)).thenReturn(true) + + assertTrue(sut.isAutomaticallyHandling(request)) + } + + @Test + fun `started payment is reported and refreshes the allowances`() = test { + nodeReady() + records = listOf(fixtures.record()) + sut.activate(identity) + val uncovered = fixtures.paymentRequest(id = "other", counterparty = fixtures.otherCounterpartyKey) + + val handled = sut.processIncomingRequests(listOf(fixtures.paymentRequest(), uncovered)) + + assertTrue(handled) + verify(executor).autoPay(eq(fixtures.paymentRequest()), eq(sut.entries.value.single().allowances), eq(identity)) + verify(executor, never()).autoPay(eq(uncovered), any(), any()) + verify(sdk, times(2)).listAllowances(any()) + } + + @Test + fun `covered request waits while the node's channels reconnect`() = test { + records = listOf(fixtures.record()) + sut.activate(identity) + val reconnecting = mock { on { isUsable } doReturn false } + lightningState.update { + it.copy(nodeLifecycleState = NodeLifecycleState.Running, channels = persistentListOf(reconnecting)) + } + val request = fixtures.paymentRequest() + + assertFalse(sut.processIncomingRequests(listOf(request))) + assertTrue(sut.isAutomaticallyHandling(request)) + verify(executor, never()).autoPay(any(), any(), any()) + } + + @Test + fun `nothing is processed without an identity`() = test { + assertFalse(sut.processIncomingRequests(listOf(fixtures.paymentRequest()))) + verify(executor, never()).autoPay(any(), any(), any()) + } + + // endregion + + // region Events + + @Test + fun `node payment events settle allowance attempts`() = test { + nodeEvents.emit( + Event.PaymentSuccessful( + paymentId = "payment-id", + paymentHash = PAYMENT_HASH, + paymentPreimage = "00".repeat(32), + feePaidMsat = 10uL, + ), + ) + nodeEvents.emit( + Event.PaymentFailed( + paymentId = PAYMENT_HASH, + paymentHash = null, + reason = PaymentFailureReason.RETRIES_EXHAUSTED, + ), + ) + + verify(executor).lightningPaymentSettled(PAYMENT_HASH, true) + verify(executor).lightningPaymentSettled(PAYMENT_HASH, false) + } + + @Test + fun `automatic payment is attributed to the contact's activity`() = test { + records = listOf(fixtures.record()) + sut.activate(identity) + localState = PaykitAllowanceLocalState( + journal = listOf( + journalEntry("a", fixtures.paymentRequest(), ALLOWANCE_ID, 1_000uL, Stage.SUCCEEDED), + ), + ) + + executorEvents.emit(PaykitAllowanceEvent.PaidAutomatically(fixtures.counterpartyKey, 1_000uL, TXID)) + + verify(activityRepo).setContact(eq(fixtures.counterpartyKey), eq(TXID), any(), any()) + assertEquals(mapOf(entryKey to 1_000uL), sut.autoPaidSats.value) + } + + @Test + fun `running node settles open lightning attempts`() = test { + lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Running) } + + verify(executor).settleOpenLightningAttempts() + } + + // endregion + + // region Helpers + + private fun nodeReady() = lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Running) } + + private fun group(vararg allowanceIds: String) = PaykitAllowanceLocalState.Group( + id = "group-1", + counterparty = fixtures.counterpartyKey, + limits = fixtures.limits, + allowanceIds = allowanceIds.toList(), + createdAtMillis = fixtures.now.toEpochMilliseconds(), + ) + + private fun receivedProposal() = fixtures.record( + localRole = AllowanceLocalRole.ALLOWEE, + state = AllowanceLifecycleState.PROPOSED, + proposedByMe = false, + ) + + @Suppress("LongParameterList") + private fun journalEntry( + attemptId: String, + request: PaykitPaymentRequest, + allowanceId: String?, + amountSats: ULong, + stage: Stage, + isAutomatic: Boolean = true, + ) = PaykitAllowanceLocalState.JournalEntry( + attemptId = attemptId, + isAutomatic = isAutomatic, + requestId = request.id, + allowanceId = allowanceId, + amountSats = amountSats, + paymentEndpointIdentifier = fixtures.lightningIdentifier, + stage = stage, + createdAtMillis = fixtures.now.toEpochMilliseconds(), + ) + + private fun linkedPeer( + publicKey: String, + state: LinkedPeerState = LinkedPeerState.LINKED, + ) = LinkedPeerRecord( + counterparty = publicKey, + state = state, + lastSyncAt = null, + lastPrivateReceiveAt = null, + failureCount = 0u, + localRecoveryAttemptId = null, + localRecoveryMarkerCreatedAt = null, + localRecoveryMarkerLastError = null, + remoteRecoveryAttemptId = null, + remoteRecoveryMarkerObservedAt = null, + ) + + // endregion +} diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt new file mode 100644 index 0000000000..fcc64ec732 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt @@ -0,0 +1,705 @@ +package to.bitkit.repositories + +import com.synonym.paykit.AccountingAmount +import com.synonym.paykit.AllowanceAccountingHistory +import com.synonym.paykit.AllowanceAccountingState +import com.synonym.paykit.AllowanceAmountRange +import com.synonym.paykit.AllowanceHistoryStatus +import com.synonym.paykit.AllowanceLifecycleState +import com.synonym.paykit.AllowanceLocalRole +import com.synonym.paykit.AllowancePeriod +import com.synonym.paykit.AllowancePeriodLimit +import com.synonym.paykit.AllowanceRecord +import com.synonym.paykit.AllowanceTerms +import com.synonym.paykit.PaymentAccountingScope +import com.synonym.paykit.PaymentAttemptRecord +import com.synonym.paykit.PaymentDisposition +import com.synonym.paykit.PaymentExecutionMode +import com.synonym.paykit.PaymentExecutionStatus +import com.synonym.paykit.PaymentOccurrenceKey +import com.synonym.paykit.PaymentOccurrenceRecord +import org.junit.After +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.lightningdevkit.ldknode.Network +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.data.keychain.Keychain +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.SubscriptionClockOffset +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +class PaykitAllowanceTest : BaseUnitTest() { + private val fixtures = PaykitAllowanceFixtures + + // region Records + + @Test + fun `record reads back sats, anchor, role and allowlist`() { + val allowlist = listOf(fixtures.lightningIdentifier, fixtures.onchainIdentifier) + val record = fixtures.record( + state = AllowanceLifecycleState.PROPOSED, + terms = fixtures.terms(allowedPaymentEndpointIdentifiers = allowlist), + proposedByMe = true, + ) + + val allowance = checkNotNull(PaykitAllowance.from(record)) + + assertEquals(fixtures.counterpartyKey, allowance.counterparty) + assertEquals(PaykitAllowanceFixtures.ALLOWANCE_ID, allowance.allowanceId) + assertEquals(PaykitAllowance.Role.ALLOWER, allowance.role) + assertTrue(allowance.isAllower) + assertTrue(allowance.isProposedByMe) + assertEquals(AllowanceLifecycleState.PROPOSED, allowance.lifecycleState) + assertEquals(5_000uL, allowance.perPaymentMaxSats) + assertEquals(50_000uL, allowance.monthlyLimitSats) + assertEquals(fixtures.septemberAnchor, allowance.monthlyAnchor) + assertNull(allowance.activeFrom) + assertNull(allowance.expiresAt) + assertEquals(allowlist, allowance.allowedPaymentEndpointIdentifiers) + assertEquals(Instant.parse("2026-09-02T10:00:00Z"), allowance.lastEventAt) + + val received = checkNotNull( + PaykitAllowance.from( + fixtures.record( + localRole = AllowanceLocalRole.ALLOWEE, + state = AllowanceLifecycleState.PROPOSED, + proposedByMe = false, + ), + ), + ) + assertEquals(PaykitAllowance.Role.ALLOWEE, received.role) + assertFalse(received.isAllower) + assertFalse(received.isProposedByMe) + assertTrue(received.isAnswerable) + } + + @Test + fun `record without usable role, terms or asset is skipped`() { + assertNull(PaykitAllowance.from(fixtures.record(localRole = null))) + assertNull(PaykitAllowance.from(fixtures.record(localRole = AllowanceLocalRole.UNKNOWN))) + assertNull(PaykitAllowance.from(fixtures.record(terms = null))) + assertNull(PaykitAllowance.from(fixtures.record(terms = fixtures.terms(asset = "usd")))) + } + + @Test + fun `status maps every lifecycle state`() { + fun status( + state: AllowanceLifecycleState, + proposedByMe: Boolean = true, + terms: AllowanceTerms = fixtures.terms(), + now: Instant = fixtures.now, + ): PaykitAllowance.Status { + val record = fixtures.record(state = state, terms = terms, proposedByMe = proposedByMe) + return checkNotNull(PaykitAllowance.from(record)).status(now) + } + + assertEquals(PaykitAllowance.Status.AWAITING_ANSWER, status(AllowanceLifecycleState.PROPOSED)) + assertEquals( + PaykitAllowance.Status.AWAITING_MY_ANSWER, + status(AllowanceLifecycleState.PROPOSED, proposedByMe = false), + ) + assertEquals(PaykitAllowance.Status.ACTIVE, status(AllowanceLifecycleState.ACCEPTED)) + assertEquals(PaykitAllowance.Status.DECLINED, status(AllowanceLifecycleState.REJECTED)) + assertEquals(PaykitAllowance.Status.ENDED, status(AllowanceLifecycleState.ENDED)) + assertEquals(PaykitAllowance.Status.CONFLICTED, status(AllowanceLifecycleState.CONFLICTED)) + assertEquals(PaykitAllowance.Status.CONFLICTED, status(AllowanceLifecycleState.UNKNOWN)) + + val expiry = Instant.parse("2026-09-20T00:00:00Z") + val expiring = fixtures.terms(expiresAt = "2026-09-20T00:00:00Z") + val accepted = AllowanceLifecycleState.ACCEPTED + assertEquals(PaykitAllowance.Status.ACTIVE, status(accepted, terms = expiring, now = expiry - 1.seconds)) + assertEquals(PaykitAllowance.Status.EXPIRED, status(accepted, terms = expiring, now = expiry)) + assertEquals(PaykitAllowance.Status.EXPIRED, status(accepted, terms = expiring, now = fixtures.now)) + + val start = Instant.parse("2026-10-01T00:00:00Z") + val scheduled = fixtures.terms(activeFrom = "2026-10-01T00:00:00Z") + assertEquals(PaykitAllowance.Status.NOT_YET_ACTIVE, status(accepted, terms = scheduled, now = fixtures.now)) + assertEquals(PaykitAllowance.Status.ACTIVE, status(accepted, terms = scheduled, now = start)) + } + + @Test + fun `sats from bitcoin amount reads the zero minimum`() { + assertEquals(0uL, PaykitAllowance.satsFromBitcoinAmount("0")) + assertEquals(0uL, PaykitAllowance.satsFromBitcoinAmount("0.00000000")) + assertEquals(5_000uL, PaykitAllowance.satsFromBitcoinAmount("0.00005")) + assertEquals(50_000uL, PaykitAllowance.satsFromBitcoinAmount("0.0005")) + assertNull(PaykitAllowance.satsFromBitcoinAmount("abc")) + } + + @Test + fun `bitcoin decimal and trusted time round trip`() { + assertEquals("0.00005", 5_000uL.toBitcoinDecimal()) + assertEquals("0.0005", 50_000uL.toBitcoinDecimal()) + assertEquals("2026-09-24T12:00:00Z", PaykitAllowanceTime.format(fixtures.now)) + val withMillis = Instant.parse("2026-09-24T12:00:00.123456Z") + assertEquals("2026-09-24T12:00:00.123Z", PaykitAllowanceTime.format(withMillis)) + assertEquals(Instant.parse("2026-09-24T12:00:00.123Z"), PaykitAllowanceTime.parse("2026-09-24T12:00:00.123Z")) + assertNull(PaykitAllowanceTime.parse("yesterday")) + } + + // endregion + + // region Monthly window + + @Test + fun `month start uses the UTC calendar month`() { + assertEquals(fixtures.septemberAnchor, PaykitAllowanceTime.monthStart(fixtures.now)) + assertEquals( + fixtures.septemberAnchor, + PaykitAllowanceTime.monthStart(Instant.parse("2026-10-01T01:00:00+02:00")), + ) + assertEquals( + Instant.parse("2026-10-01T00:00:00Z"), + PaykitAllowanceTime.monthStart(Instant.parse("2026-09-30T23:30:00-02:00")), + ) + } + + @Test + fun `monthly window from a first of month anchor`() { + val cases = listOf( + Triple("2026-09-01T00:00:00Z", "2026-09-01T00:00:00Z", "2026-10-01T00:00:00Z"), + Triple("2026-09-24T12:00:00Z", "2026-09-01T00:00:00Z", "2026-10-01T00:00:00Z"), + Triple("2026-09-30T23:59:59Z", "2026-09-01T00:00:00Z", "2026-10-01T00:00:00Z"), + Triple("2026-10-01T00:00:00Z", "2026-10-01T00:00:00Z", "2026-11-01T00:00:00Z"), + Triple("2026-12-31T23:59:59Z", "2026-12-01T00:00:00Z", "2027-01-01T00:00:00Z"), + Triple("2027-02-10T08:00:00Z", "2027-02-01T00:00:00Z", "2027-03-01T00:00:00Z"), + Triple("2026-08-31T23:59:59Z", "2026-08-01T00:00:00Z", "2026-09-01T00:00:00Z"), + Triple("2026-07-15T12:00:00Z", "2026-07-01T00:00:00Z", "2026-08-01T00:00:00Z"), + ) + + for ((date, start, end) in cases) { + val window = PaykitAllowanceTime.monthlyWindow(fixtures.septemberAnchor, Instant.parse(date)) + assertEquals(Instant.parse(start) to Instant.parse(end), window, "window for $date") + } + } + + @Test + fun `monthly window clamps a January 31 anchor in February`() { + val anchor = Instant.parse("2026-01-31T12:30:00Z") + + val midFebruary = PaykitAllowanceTime.monthlyWindow(anchor, Instant.parse("2026-02-15T00:00:00Z")) + assertEquals(anchor, midFebruary.first) + assertEquals(Instant.parse("2026-02-28T12:30:00Z"), midFebruary.second) + + val lateFebruary = PaykitAllowanceTime.monthlyWindow(anchor, Instant.parse("2026-02-28T12:30:00Z")) + assertEquals(Instant.parse("2026-02-28T12:30:00Z"), lateFebruary.first) + } + + /** + * Vectors from paykit-lib `test_anchored_months_clamp_from_original_anchor`: every boundary is counted from the + * original anchor, so the window after a clamped February still ends on March 31. + */ + @Test + fun `monthly window after a clamped February matches paykit anchored arithmetic`() { + val anchor = Instant.parse("2026-01-31T12:30:00Z") + val vectors = listOf( + Triple("2026-02-28T12:30:00Z", "2026-02-28T12:30:00Z", "2026-03-31T12:30:00Z"), + Triple("2026-03-30T12:30:00Z", "2026-02-28T12:30:00Z", "2026-03-31T12:30:00Z"), + Triple("2025-12-01T00:00:00Z", "2025-11-30T12:30:00Z", "2025-12-31T12:30:00Z"), + ) + for ((date, start, end) in vectors) { + val window = PaykitAllowanceTime.monthlyWindow(anchor, Instant.parse(date)) + assertEquals(Instant.parse(start) to Instant.parse(end), window, "window for $date") + } + + val beforeMarchAnchor = PaykitAllowanceTime.monthlyWindow( + Instant.parse("2026-03-31T00:00:00Z"), + Instant.parse("2026-01-15T00:00:00Z"), + ) + assertEquals(Instant.parse("2025-12-31T00:00:00Z"), beforeMarchAnchor.first) + assertEquals(Instant.parse("2026-01-31T00:00:00Z"), beforeMarchAnchor.second) + + val allowance = fixtures.allowance(monthlyAnchor = anchor) + val lateMarchAttempt = fixtures.capacityAttempt(sats = 50_000uL, at = "2026-03-29T09:00:00Z") + assertFalse( + PaykitAllowanceCapacity.fits( + 1_000uL, + allowance, + listOf(lateMarchAttempt), + Instant.parse("2026-03-30T12:30:00Z"), + ), + "A March 29 attempt at the cap must count on March 30", + ) + } + + // endregion + + // region Capacity + + @Test + fun `capacity fits under the cap`() { + val attempts = listOf(fixtures.capacityAttempt(sats = 20_000uL, at = "2026-09-05T10:00:00Z")) + + assertEquals(20_000uL, fixtures.usedSats(attempts)) + assertTrue(PaykitAllowanceCapacity.fits(5_000uL, fixtures.allowance(), attempts, fixtures.now)) + } + + @Test + fun `capacity fits exactly at the cap and rejects one sat over`() { + val attempts = listOf( + fixtures.capacityAttempt(sats = 20_000uL, at = "2026-09-05T10:00:00Z"), + fixtures.capacityAttempt(sats = 25_000uL, at = "2026-09-12T10:00:00Z"), + ) + + assertTrue(PaykitAllowanceCapacity.fits(5_000uL, fixtures.allowance(), attempts, fixtures.now)) + val noPerPaymentMaximum = fixtures.allowance(perPaymentMaxSats = null) + assertFalse(PaykitAllowanceCapacity.fits(5_001uL, noPerPaymentMaximum, attempts, fixtures.now)) + } + + @Test + fun `capacity rejects over the per payment maximum`() { + val allowance = fixtures.allowance() + + assertTrue(PaykitAllowanceCapacity.fits(5_000uL, allowance, emptyList(), fixtures.now)) + assertFalse(PaykitAllowanceCapacity.fits(5_001uL, allowance, emptyList(), fixtures.now)) + } + + @Test + fun `capacity ignores failed attempts, previous months and other allowances`() { + val attempts = listOf( + fixtures.capacityAttempt(sats = 45_000uL, at = "2026-09-05T10:00:00Z", isLive = false), + fixtures.capacityAttempt(sats = 50_000uL, at = "2026-08-31T23:59:59Z"), + fixtures.capacityAttempt( + allowanceId = PaykitAllowanceFixtures.SECOND_ALLOWANCE_ID, + sats = 50_000uL, + at = "2026-09-10T10:00:00Z", + ), + fixtures.capacityAttempt(sats = 1_000uL, at = "2026-09-01T00:00:00Z"), + fixtures.capacityAttempt(sats = 10_000uL, at = "2026-09-12T10:00:00Z"), + ) + + assertEquals(11_000uL, fixtures.usedSats(attempts)) + assertTrue(PaykitAllowanceCapacity.fits(5_000uL, fixtures.allowance(), attempts, fixtures.now)) + } + + @Test + fun `capacity without a per payment or monthly limit never fits`() { + val attempts = listOf(fixtures.capacityAttempt(sats = 1_000_000uL, at = "2026-09-05T10:00:00Z")) + + assertFalse( + PaykitAllowanceCapacity.fits(5_000uL, fixtures.allowance(monthlyLimitSats = null), attempts, fixtures.now) + ) + assertFalse( + PaykitAllowanceCapacity.fits(5_000uL, fixtures.allowance(perPaymentMaxSats = null), attempts, fixtures.now) + ) + } + + @Test + fun `attempts from history keep automatic allowance attempts`() { + val history = AllowanceAccountingHistory( + associations = emptyList(), + occurrences = listOf( + fixtures.occurrence( + requestId = "req-1", + attempts = listOf( + fixtures.attemptRecord( + id = "failed", + amount = "0.0001", + admittedAt = "2026-09-02T10:00:00Z", + status = PaymentExecutionStatus.FAILED, + ), + fixtures.attemptRecord( + id = "paid", + amount = "0.0001", + admittedAt = "2026-09-03T10:00:00Z", + status = PaymentExecutionStatus.SUCCEEDED, + ), + ), + ), + fixtures.occurrence( + requestId = "req-2", + attempts = listOf( + fixtures.attemptRecord( + id = "manual", + mode = PaymentExecutionMode.MANUAL, + allowanceId = null, + status = PaymentExecutionStatus.SUCCEEDED, + ), + fixtures.attemptRecord( + id = "unattributed", + allowanceId = null, + status = PaymentExecutionStatus.SUCCEEDED, + ), + fixtures.attemptRecord( + id = "open", + amount = "0.00002", + admittedAt = "2026-09-05T10:00:00Z", + status = PaymentExecutionStatus.SUBMITTED, + ), + ), + ), + ), + watermarks = emptyList(), + ) + + val attempts = PaykitAllowanceCapacity.attempts(history) + + val walletId = PaykitAllowanceFixtures.ALLOWANCE_ID + assertEquals( + listOf( + attempt(walletId, 10_000uL, "2026-09-02T10:00:00Z", isLive = false), + attempt(walletId, 10_000uL, "2026-09-03T10:00:00Z", isLive = true), + attempt(walletId, 2_000uL, "2026-09-05T10:00:00Z", isLive = true), + ), + attempts, + ) + } + + @Test + fun `status and capacity use the given time`() { + val allowance = fixtures.allowance(expiresAt = fixtures.now + 30.days) + val attempts = listOf(fixtures.capacityAttempt(sats = 50_000uL, at = "2026-09-10T10:00:00Z")) + + assertEquals(PaykitAllowance.Status.ACTIVE, allowance.status(fixtures.now)) + assertEquals(PaykitAllowance.Status.EXPIRED, allowance.status(fixtures.now + 30.days)) + assertFalse(PaykitAllowanceCapacity.fits(1uL, allowance, attempts, fixtures.now)) + assertTrue(PaykitAllowanceCapacity.fits(1uL, allowance, attempts, Instant.parse("2026-10-01T00:00:00Z"))) + } + + @Test + fun `status and capacity ignore the subscription clock offset`() { + SubscriptionClockOffset.setOffsetDays(365) + assumeTrue( + "The subscription clock offset is unavailable in this build", + SubscriptionClockOffset.offsetDays == 365, + ) + assertTrue(SubscriptionClockOffset.subscriptionNow() > Clock.System.now() + 364.days) + + val allowance = fixtures.allowance(expiresAt = fixtures.now + 30.days) + val attempts = listOf(fixtures.capacityAttempt(sats = 50_000uL, at = "2026-09-10T10:00:00Z")) + + assertEquals(PaykitAllowance.Status.ACTIVE, allowance.status(fixtures.now)) + assertEquals(50_000uL, fixtures.usedSats(attempts)) + assertFalse(PaykitAllowanceCapacity.fits(1uL, allowance, attempts, fixtures.now)) + } + + @After + fun resetSubscriptionClockOffset() { + SubscriptionClockOffset.setOffsetDays(0) + } + + // endregion + + // region Grouping and terms + + @Test + fun `entry primary prefers an accepted allowance`() { + val first = fixtures.allowance(perPaymentMaxSats = 5_000uL) + val second = fixtures.allowance( + allowanceId = PaykitAllowanceFixtures.SECOND_ALLOWANCE_ID, + perPaymentMaxSats = 1uL, + ) + + val entry = PaykitAllowanceEntry(id = "group", allowances = listOf(first, second), limits = fixtures.limits) + assertEquals(PaykitAllowanceFixtures.ALLOWANCE_ID, entry.primary.allowanceId) + assertEquals(5_000uL, entry.perPaymentMaxSats) + + val firstDeclined = first.copy(lifecycleState = AllowanceLifecycleState.REJECTED) + val acceptedSecond = PaykitAllowanceEntry(id = "g", allowances = listOf(firstDeclined, second), limits = null) + assertEquals(PaykitAllowanceFixtures.SECOND_ALLOWANCE_ID, acceptedSecond.primary.allowanceId) + + val bothProposed = listOf( + first.copy(lifecycleState = AllowanceLifecycleState.PROPOSED), + second.copy(lifecycleState = AllowanceLifecycleState.PROPOSED), + ) + val proposed = PaykitAllowanceEntry(id = "p", allowances = bothProposed, limits = null) + assertEquals(PaykitAllowanceFixtures.ALLOWANCE_ID, proposed.primary.allowanceId) + } + + @Test + fun `allowed endpoints are bolt11 and the network's on-chain methods`() { + assertEquals( + listOf( + "btc-lightning-bolt11", + "btc-regtest-p2tr", + "btc-regtest-p2wpkh", + "btc-regtest-p2sh", + "btc-regtest-p2pkh", + ), + PaykitAllowanceRepo.allowedPaymentEndpointIdentifiers(Network.REGTEST), + ) + } + + // endregion + + // region Store + + @Test + fun `store keeps one state per identity and survives a corrupt value`() = test { + val keychain = mock() + var stored: String? = null + whenever(keychain.loadString(any())).thenAnswer { stored } + whenever(keychain.upsertString(any(), any())).doSuspendableAnswer { stored = it.getArgument(1) } + val store = PaykitAllowanceStore(keychain) + val entry = PaykitAllowanceLocalState.JournalEntry( + attemptId = "attempt-1", + isAutomatic = true, + requestId = fixtures.paymentRequest().id, + allowanceId = PaykitAllowanceFixtures.ALLOWANCE_ID, + amountSats = 1_000uL, + paymentEndpointIdentifier = fixtures.lightningIdentifier, + paymentHash = "ab".repeat(32), + stage = PaykitAllowanceLocalState.Stage.SENT, + createdAtMillis = fixtures.now.toEpochMilliseconds(), + ) + val group = PaykitAllowanceLocalState.Group( + id = "group-1", + counterparty = fixtures.counterpartyKey, + limits = fixtures.limits, + allowanceIds = listOf(PaykitAllowanceFixtures.ALLOWANCE_ID), + createdAtMillis = 1L, + ) + val state = PaykitAllowanceLocalState( + groups = listOf(group), + journal = listOf(entry), + presentedProposalIds = setOf("proposal"), + notifiedRequestIds = setOf("request"), + lastTrustedTimeMillis = 42L, + ) + + store.save(fixtures.identityKey, state) + + assertEquals(state, store.load(fixtures.identityKey)) + assertEquals(PaykitAllowanceLocalState(), store.load(fixtures.otherCounterpartyKey)) + val loaded = store.load(fixtures.identityKey) + assertEquals( + group, + loaded.group(containing = PaykitAllowanceFixtures.ALLOWANCE_ID, counterparty = group.counterparty), + ) + + stored = "{not json" + assertEquals(PaykitAllowanceLocalState(), store.load(fixtures.identityKey)) + } + + // endregion +} + +/** Shared builders for the Allowance suites. */ +private fun attempt(allowanceId: String, sats: ULong, at: String, isLive: Boolean) = PaykitAllowanceCapacity.Attempt( + counterparty = PaykitAllowanceFixtures.counterpartyKey, + allowanceId = allowanceId, + amountSats = sats, + admittedAt = Instant.parse(at), + isLive = isLive, +) + +internal object PaykitAllowanceFixtures { + const val ALLOWANCE_ID = "allowance-1" + const val SECOND_ALLOWANCE_ID = "allowance-second" + val identityKey = "pubky" + "z".repeat(52) + val counterpartyKey = "pubky" + "y".repeat(52) + val otherCounterpartyKey = "pubky" + "x".repeat(52) + val lightningIdentifier: String get() = MethodId.Bolt11.rawValue + val onchainIdentifier: String get() = MethodId.P2wpkh.rawValue + val now: Instant = Instant.parse("2026-09-24T12:00:00Z") + val septemberAnchor: Instant = Instant.parse("2026-09-01T00:00:00Z") + val limits = PaykitAllowanceLimits( + perPaymentUsd = 5, + monthlyUsd = 50, + perPaymentSats = 5_000uL, + monthlySats = 50_000uL, + ) + + @Suppress("LongParameterList") + fun terms( + perPaymentMaximum: String? = "0.00005", + monthlyLimit: String? = "0.0005", + anchor: String? = "2026-09-01T00:00:00Z", + activeFrom: String? = null, + expiresAt: String? = null, + asset: String = PaykitIssuerInterop.BITCOIN_ASSET, + allowedPaymentEndpointIdentifiers: List? = listOf(lightningIdentifier), + ): AllowanceTerms { + val termsAsset = asset + val termsActiveFrom = activeFrom + val termsExpiresAt = expiresAt + val allowlist = allowedPaymentEndpointIdentifiers + val periodAnchor = anchor + val range = perPaymentMaximum?.let { max -> + mock { + on { minimum() } doReturn "0" + on { maximum() } doReturn max + } + } + val monthlyPeriod = mock { + on { kind() } doReturn "anchored" + on { every() } doReturn 1uL + on { unit() } doReturn "month" + on { anchor() } doReturn periodAnchor + } + val periodLimit = mock { + on { amountLimit() } doReturn monthlyLimit + on { paymentCountLimit() } doReturn null + on { period() } doReturn monthlyPeriod + } + return mock { + on { asset() } doReturn termsAsset + on { perPaymentAmount() } doReturn range + on { periodLimits() } doReturn listOf(periodLimit) + on { lifetimeAmountLimit() } doReturn null + on { activeFrom() } doReturn termsActiveFrom + on { expiresAt() } doReturn termsExpiresAt + on { allowedPaymentEndpointIdentifiers() } doReturn allowlist + } + } + + @Suppress("LongParameterList") + fun record( + allowanceId: String = ALLOWANCE_ID, + counterparty: String = counterpartyKey, + localRole: AllowanceLocalRole? = AllowanceLocalRole.ALLOWER, + state: AllowanceLifecycleState = AllowanceLifecycleState.ACCEPTED, + historyStatus: AllowanceHistoryStatus = AllowanceHistoryStatus.CONSISTENT, + terms: AllowanceTerms? = terms(), + proposedByMe: Boolean = true, + lastEventAt: String? = "2026-09-02T10:00:00Z", + ) = AllowanceRecord( + counterparty = counterparty, + allowanceId = allowanceId, + localRole = localRole, + state = state, + historyStatus = historyStatus, + proposalEventId = "proposal-$allowanceId", + terms = terms, + proposalStreamItemId = if (proposedByMe) null else 1uL, + proposalOutboundMessageId = if (proposedByMe) 1uL else null, + proposalOutboundStatus = null, + acceptanceEventId = null, + acceptanceOutboundStatus = null, + rejectionEventId = null, + rejectionOutboundStatus = null, + endEventId = null, + endOutboundStatus = null, + pendingCausalEventIds = emptyList(), + conflictEventIds = emptyList(), + lastStreamItemId = null, + lastOutboundMessageId = null, + lastOutboundStatus = null, + lastEventAt = lastEventAt, + invalidReason = null, + ) + + @Suppress("LongParameterList") + fun allowance( + allowanceId: String = ALLOWANCE_ID, + counterparty: String = counterpartyKey, + role: PaykitAllowance.Role = PaykitAllowance.Role.ALLOWER, + state: AllowanceLifecycleState = AllowanceLifecycleState.ACCEPTED, + perPaymentMaxSats: ULong? = 5_000uL, + monthlyLimitSats: ULong? = 50_000uL, + monthlyAnchor: Instant? = septemberAnchor, + expiresAt: Instant? = null, + lastEventAt: Instant? = null, + ) = PaykitAllowance( + id = PaykitAllowance.Id(counterparty, allowanceId), + role = role, + lifecycleState = state, + isProposedByMe = role == PaykitAllowance.Role.ALLOWER, + perPaymentMaxSats = perPaymentMaxSats, + monthlyLimitSats = monthlyLimitSats, + monthlyAnchor = monthlyAnchor, + expiresAt = expiresAt, + allowedPaymentEndpointIdentifiers = listOf(lightningIdentifier), + lastEventAt = lastEventAt, + ) + + fun capacityAttempt( + allowanceId: String = ALLOWANCE_ID, + counterparty: String = counterpartyKey, + sats: ULong, + at: String, + isLive: Boolean = true, + ) = PaykitAllowanceCapacity.Attempt(counterparty, allowanceId, sats, Instant.parse(at), isLive) + + fun usedSats(attempts: List): ULong = + PaykitAllowanceCapacity.usedSats(counterpartyKey, ALLOWANCE_ID, attempts, septemberAnchor, now) + + fun accountingAmount(amount: String, currency: String = PaykitIssuerInterop.BITCOIN_ASSET): AccountingAmount = + mock { + on { value() } doReturn amount + on { asset() } doReturn currency + } + + @Suppress("LongParameterList") + fun attemptRecord( + id: String, + mode: PaymentExecutionMode = PaymentExecutionMode.AUTOMATIC, + allowanceId: String? = ALLOWANCE_ID, + amount: String = "0.00001", + admittedAt: String = "2026-09-24T12:00:00Z", + status: PaymentExecutionStatus, + epoch: String = "epoch-1", + ) = PaymentAttemptRecord( + attemptId = id, + mode = mode, + allowanceId = allowanceId, + associationRevision = allowanceId?.let { 1uL }, + amount = accountingAmount(amount), + admittedAt = admittedAt, + status = status, + epoch = epoch, + ) + + fun accountingScope(paymentRequestId: String) = PaymentAccountingScope( + localPublicKey = identityKey, + counterparty = counterpartyKey, + paymentRequestId = paymentRequestId, + ) + + fun occurrence( + requestId: String, + attempts: List, + allowanceId: String? = ALLOWANCE_ID, + ) = PaymentOccurrenceRecord( + key = PaymentOccurrenceKey(request = accountingScope(requestId), billingPeriod = null), + disposition = PaymentDisposition.Automatic, + allowanceId = allowanceId, + associationRevision = allowanceId?.let { 1uL }, + attempts = attempts, + ) + + fun accountingState( + revision: ULong = 1uL, + epoch: String = "epoch-1", + requiresReconciliation: Boolean = false, + occurrences: List = emptyList(), + ) = AllowanceAccountingState( + revision = revision, + epoch = epoch, + requiresReconciliation = requiresReconciliation, + history = AllowanceAccountingHistory( + associations = emptyList(), + occurrences = occurrences, + watermarks = emptyList(), + ), + ) + + @Suppress("LongParameterList") + fun paymentRequest( + id: String = "550e8400-e29b-41d4-a716-446655440001", + counterparty: String = counterpartyKey, + amountSats: ULong = 1_000uL, + createdAt: String = "2026-09-24T11:00:00Z", + ) = PaykitPaymentRequest( + paymentRequestId = id, + counterparty = counterparty, + amount = PaykitAmount(PaykitAsset.BTC, amountSats), + paymentReference = "fixture-reference", + createdAt = Instant.parse(createdAt), + expiresAt = null, + acceptedPaymentEndpointIdentifiers = listOf(lightningIdentifier), + ) +} diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 039ec40272..98d24527c8 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -99,6 +99,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { private const val PAYMENT_REQUEST_ID = "550e8400-e29b-41d4-a716-446655440000" private const val PAYMENT_HASH = "66687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925" private const val ONCHAIN_ADDRESS = "bcrt1qpaymentproof" + private const val ALLOWANCE_ID = "4f1c2d3e-5a6b-4c7d-8e9f-0a1b2c3d4e5f" private val PREIMAGE = "00".repeat(32) } @@ -180,7 +181,7 @@ 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(), isNull(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull())) .doSuspendableAnswer { assertEquals(txid, PaykitPaymentProofStore(diskKeychain).load().single().proofData) delay(5.seconds) @@ -216,7 +217,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic).getOrThrow() whenever(store.save(any())).doSuspendableAnswer { throw AppError("disk unavailable") } - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull())) .doSuspendableAnswer { delay(5.seconds) throw AppError("delivery unavailable") @@ -266,14 +267,20 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.identityStatus()).thenReturn(status) paymentProofRepo().reconcile() assertEquals(txid, storedProofs.single().proofData) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) } storedProofs = emptyList() whenever(paykitSdkService.identityStatus()) .thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) repo.reconcile() assertTrue(storedProofs.isEmpty()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) } @Test @@ -281,7 +288,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { storedProofs = listOf(readyLightningProof(PAYMENT_REQUEST_ID).copy(paymentAppId = "")) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) paymentProofRepo().reconcile() - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) assertEquals("", storedProofs.single().paymentAppId) assertTrue(storedProofs.single().paymentStarted) } @@ -489,7 +499,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(), isNull(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull())) .thenThrow(IllegalStateException("temporary failure")) .thenReturn(record) val firstRepo = paymentProofRepo() @@ -513,6 +523,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { proofJson = proofCaptor.capture(), billingPeriod = isNull(), conversionQuoteId = isNull(), + allowanceId = isNull(), ) assertEquals(MethodId.Bolt11.rawValue, endpointCaptor.lastValue) assertEquals( @@ -533,7 +544,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) } whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull())) .thenReturn(record) val lightning = readyLightningProof(PAYMENT_REQUEST_ID) val onchain = lightning.copy( @@ -564,6 +575,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { proofJson = any(), billingPeriod = isNull(), conversionQuoteId = isNull(), + allowanceId = isNull(), ) } } @@ -581,7 +593,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentRequestRecord(paymentRequestId = secondPaymentRequestId), ), ) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull())) .thenThrow(IllegalStateException("temporary failure")) .thenReturn(paymentRequestRecord(paymentRequestId = secondPaymentRequestId)) @@ -596,6 +608,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { proofJson = any(), billingPeriod = isNull(), conversionQuoteId = isNull(), + allowanceId = isNull(), ) assertEquals(listOf(PAYMENT_REQUEST_ID, secondPaymentRequestId), paymentRequestIdCaptor.allValues) assertEquals(listOf(PAYMENT_REQUEST_ID), storedProofs.map { it.requestId.paymentRequestId }) @@ -617,7 +630,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(lightningRepo.getPayments()).thenReturn(Result.success(listOf(payment))) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever( - paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) ).thenReturn(record) val firstRepo = paymentProofRepo() @@ -637,6 +650,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { proofJson = proofCaptor.capture(), billingPeriod = isNull(), conversionQuoteId = isNull(), + allowanceId = isNull(), ) assertEquals( """{"data":"$PREIMAGE","type":"${PaykitPaymentProofKind.Lightning.type}"}""", @@ -645,13 +659,47 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.isEmpty()) } + @Test + fun `allowance proof is submitted with its allowance id`() = test { + val record = paymentRequestRecord() + val request = paymentRequest(MethodId.Bolt11.rawValue) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any())) + .thenReturn(record) + val sut = paymentProofRepo() + + sut.prepare( + request, + MethodId.Bolt11.rawValue, + "bitkit", + PaykitPaymentProofKind.Lightning, + allowanceId = ALLOWANCE_ID, + ).getOrThrow() + sut.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() + assertEquals(ALLOWANCE_ID, storedProofs.single().allowanceId) + sut.completeLightningPayment(PAYMENT_HASH, PREIMAGE) + sut.reconcile() + + verify(paykitSdkService).submitPaymentProof( + counterparty = any(), + paymentRequestId = any(), + paymentAppId = eq("bitkit"), + paymentEndpointIdentifier = eq(MethodId.Bolt11.rawValue), + proofJson = any(), + billingPeriod = isNull(), + conversionQuoteId = isNull(), + allowanceId = eq(ALLOWANCE_ID), + ) + assertTrue(storedProofs.isEmpty()) + } + @Test fun `lightning proof completes without prepared proof`() = test { val record = paymentRequestRecord() val request = paymentRequest(MethodId.Bolt11.rawValue) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever( - paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) ).thenReturn(record) val repo = paymentProofRepo() @@ -659,7 +707,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) repo.reconcile() - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) assertTrue(storedProofs.isEmpty()) } @@ -673,7 +721,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.completeLightningPayment(PAYMENT_HASH, "01".repeat(32)) assertNull(storedProofs.single().proofData) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) } @Test @@ -697,7 +748,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(PaykitPaymentRequestError.OperationInProgress, result.exceptionOrNull()) assertTrue(storedProofs.isEmpty()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) } @Test @@ -727,7 +781,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.failLightningPayment(PAYMENT_HASH) assertTrue(storedProofs.isEmpty()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) } @Test @@ -742,7 +799,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever(lightningRepo.getPayments()).thenReturn(Result.success(emptyList())) whenever( - paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) ).thenReturn(record) for (error in errors) { val request = paymentRequest(MethodId.Bolt11.rawValue) @@ -773,7 +830,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify( paykitSdkService, times(errors.size) - ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) } @Test @@ -807,7 +864,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever( - paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) ).thenReturn(record) val repo = paymentProofRepo() @@ -833,6 +890,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { proofCaptor.capture(), isNull(), conversionQuoteId = isNull(), + allowanceId = isNull(), ) assertEquals(MethodId.P2wpkh.rawValue, endpointCaptor.firstValue) assertEquals( @@ -953,7 +1011,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertNull(storedProofs.single().paymentIdentifier) assertNull(storedProofs.single().proofData) assertTrue(storedProofs.single().paymentStarted) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), isNull()) } @Test @@ -964,14 +1025,16 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever( - paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) ).thenReturn(record) val repo = paymentProofRepo() repo.completeOnchainPayment(request, txid, endpoint, "bitkit", OnchainSendOutcome.Accepted(txid)) repo.reconcile() - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), eq(endpoint), any(), isNull(), isNull()) + verify( + paykitSdkService + ).submitPaymentProof(any(), any(), any(), eq(endpoint), any(), isNull(), isNull(), isNull()) assertTrue(storedProofs.isEmpty()) } @@ -985,7 +1048,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever( - paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), isNull()) ).thenReturn(record) val repo = paymentProofRepo() @@ -1003,6 +1066,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { proofJson = any(), billingPeriod = periodCaptor.capture(), conversionQuoteId = isNull(), + allowanceId = isNull(), ) assertEquals(period, periodCaptor.firstValue) } @@ -1029,7 +1093,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.Bolt11.rawValue, billingPeriod = currentPeriod) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever( - paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), isNull()) ).thenReturn(record) val repo = paymentProofRepo() @@ -1038,7 +1102,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) repo.reconcile() - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), isNull()) } @Test @@ -1080,7 +1144,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever( - paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) ).thenReturn(record) val repo = paymentProofRepo() @@ -1095,7 +1159,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { OnchainSendOutcome.Accepted(txid) ) - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) assertTrue(storedProofs.isEmpty()) } @@ -1105,7 +1169,7 @@ 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(), isNull(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull())) .thenThrow(IllegalStateException("transient submission failure")) val repo = paymentProofRepo() @@ -1121,7 +1185,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) assertEquals(txid, storedProofs.single().proofData) - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) } @Test @@ -1131,7 +1195,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever( - paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) ).thenReturn(record) val repo = paymentProofRepo() @@ -1147,7 +1211,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) repo.reconcile() - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) assertEquals(txid, storedProofs.single().proofData) } @@ -1159,7 +1223,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever( - paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) ).thenReturn(record) val repo = paymentProofRepo() @@ -1179,6 +1243,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { proofJson = proofCaptor.capture(), billingPeriod = isNull(), conversionQuoteId = isNull(), + allowanceId = isNull(), ) assertEquals(endpoint, endpointCaptor.firstValue) assertEquals( @@ -1195,7 +1260,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever( - paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) ).thenReturn(record) val repo = paymentProofRepo() @@ -1214,6 +1279,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { proofJson = proofCaptor.capture(), billingPeriod = isNull(), conversionQuoteId = isNull(), + allowanceId = isNull(), ) assertTrue(proofCaptor.firstValue.contains(txid)) } @@ -1236,7 +1302,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) ) assertEquals(listOf(original), storedProofs) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), isNull()) } @Test @@ -1293,7 +1362,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ), ) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull())) .thenThrow(IllegalStateException("delivery temporarily unavailable")) assertTrue( @@ -1345,7 +1414,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt.copy(payerIdentity = null)) assertTrue(repo.authorizeOnchainRecovery(request, attempt.copy(payerIdentity = null)).isFailure) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), isNull()) } @Test @@ -1426,7 +1498,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertNull(storedProofs.single().proofData) assertEquals("ef".repeat(32), storedProofs.single().paymentIdentifier) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), isNull()) verify(lightningRepo, never()).finishAcceptedShopActivity(any(), any()) } @@ -1441,7 +1516,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentRequestRecord(paymentRequestId = secondPaymentRequestId), ), ) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull())) .thenReturn(paymentRequestRecord()) val repo = paymentProofRepo() repo.prepare(firstRequest, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() @@ -1472,7 +1547,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.single().onchainMatchingTransactionIdsBeforeAttempt.isEmpty()) assertNull(storedProofs.single().proofData) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), isNull()) } @Test @@ -1524,7 +1602,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.single().paymentStarted) assertNull(storedProofs.single().proofData) verify(lightningRepo, never()).completeAcceptedShopFollowup(any(), any()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), isNull()) whenever( paykitSdkService.identityStatus() @@ -1533,7 +1614,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.single().paymentStarted) assertNull(storedProofs.single().proofData) verify(lightningRepo, never()).completeAcceptedShopFollowup(any(), any()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), isNull()) whenever( paykitSdkService.identityStatus() ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) @@ -1551,6 +1635,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { any(), isNull(), isNull(), + isNull(), ) } @@ -1560,7 +1645,7 @@ 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(), isNull(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull())) .thenThrow(IllegalStateException("delivery unavailable")) .thenReturn(record) val repo = paymentProofRepo() @@ -1593,6 +1678,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { proofCaptor.capture(), isNull(), isNull(), + isNull(), ) assertTrue(proofCaptor.allValues.all { it.contains(txid) }) verify(lightningRepo, never()).completeAcceptedShopFollowup(newerRequest.id, "cd".repeat(32)) @@ -1616,7 +1702,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentProofRepo().reconcile() assertEquals(1, storedProofs.size) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), isNull()) verify(lightningRepo, never()).completeAcceptedShopFollowup(any(), any()) } @@ -1630,7 +1719,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertFalse(repo.completeOnchainPayment(request, "ab".repeat(32), MethodId.P2wpkh.rawValue, "bitkit")) assertNull(storedProofs.single().proofData) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), isNull()) } @Test @@ -1653,7 +1745,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { acceptedAttempt(request, txid).copy(evidence = OnchainSendEvidence.Observed), ) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull())) .thenThrow(IllegalStateException("delivery unavailable")) paymentProofRepo().reconcile() @@ -1679,7 +1771,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) assertFalse(storedProofs.single().onchainAcceptanceVerified) assertNull(storedProofs.single().proofData) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), isNull()) } @Test @@ -1700,7 +1795,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentProofRepo().reconcile() assertEquals(1, storedProofs.size) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), isNull()) verify(lightningRepo, never()).completeAcceptedShopFollowup(any(), any()) } @@ -2232,7 +2330,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ).thenReturn(Result.success(true)) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) whenever( - paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) ).thenReturn(paymentRequestRecord()) paymentProofRepo().reconcile() verify(paykitSdkService).submitPaymentProof( @@ -2242,6 +2340,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { eq(MethodId.P2wpkh.rawValue), eq("""{"data":"$txid","type":"bitcoin-onchain-txid"}"""), isNull(), + isNull(), isNull() ) assertTrue(storedProofs.isEmpty()) @@ -2270,13 +2369,16 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) repo.failOnchainPayment(request) assertEquals(1, storedProofs.size) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) whenever( hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amount.atomic) ).thenReturn(Result.success(true)) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull())) .thenReturn(paymentRequestRecord()) whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedAttempt(request, "cd".repeat(32))) paymentProofRepo().reconcile() @@ -2288,6 +2390,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { eq(MethodId.P2wpkh.rawValue), eq("""{"data":"$txid","type":"bitcoin-onchain-txid"}"""), isNull(), + isNull(), isNull() ) assertTrue(storedProofs.isEmpty()) @@ -2312,10 +2415,13 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { .thenReturn(Result.success(false)) paymentProofRepo().reconcile() assertEquals(txid, storedProofs.single().proofData) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amount.atomic)) .thenReturn(Result.success(true)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull())) .thenReturn(paymentRequestRecord()) paymentProofRepo().reconcile() assertTrue(storedProofs.isEmpty()) @@ -2343,7 +2449,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amount.atomic)) .thenReturn(Result.success(false)) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull())) .thenReturn(paymentRequestRecord()) repo.reconcile() assertTrue(storedProofs.isEmpty()) @@ -2356,6 +2462,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { eq("""{"data":"$txid","type":"bitcoin-onchain-txid"}"""), isNull(), isNull(), + isNull(), ) verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) } @@ -2382,7 +2489,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(completed, storedProofs.single()) verify(hwWalletRepo, times(2)).observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amount.atomic) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) } @@ -2406,7 +2516,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertFalse(storedProofs.single().onchainAcceptanceVerified) verify(hwWalletRepo, times(2)).observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amount.atomic) verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) } @Test @@ -2435,7 +2548,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(repo.completeHardwareOnchainPayment(request.id, walletId, txid, LOCAL_IDENTITY)) assertTrue(storedProofs.first { it.identity == LOCAL_IDENTITY }.onchainAcceptanceVerified) assertEquals(other, storedProofs.first { it.identity == COUNTERPARTY }) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) } @Test @@ -2537,7 +2653,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { .exceptionOrNull() ) verify(hwWalletRepo, times(1)).broadcastFunding(signed) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) } @Test @@ -2910,7 +3029,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertNull(reopened.retainedHardwareOnchainRequest(request)) assertEquals(listOf(proof), storedProofs) verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull()) } private fun paymentProofRepo(store: PaykitPaymentProofStore = this.store) = PaykitPaymentProofRepo( diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 6a89a8adb2..3d89b86439 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -1420,6 +1420,41 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { backgroundWork.await() } + @Test + fun `automatic acceptance saves this install as owner before it runs and keeps the request payable here`() = test { + val proposed = paymentRequestRecord() + val accepted = proposed.copy(state = PaymentRequestLifecycleState.ACCEPTED) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed)) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + val requestId = PaykitPaymentRequestId(PAYMENT_REQUEST_ID, COUNTERPARTY) + + val result = sut.acceptOnThisInstall(request) { + verify(presentationStore).addAcceptedOneTimeId(LOCAL_IDENTITY, requestId) + "accepted" + } + + assertEquals("accepted", result.getOrThrow()) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(accepted)) + sut.refresh().getOrThrow() + sut.ensurePaymentAllowed(request).getOrThrow() + } + + @Test + fun `failed automatic acceptance drops this install as owner`() = test { + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + + val result = sut.acceptOnThisInstall(request) { error("claimed by another app") } + + assertTrue(result.isFailure) + verify(presentationStore).removeAcceptedOneTimeIds( + LOCAL_IDENTITY, + setOf(PaykitPaymentRequestId(PAYMENT_REQUEST_ID, COUNTERPARTY)), + ) + } + @Test fun `local acceptance survives refresh and reconnect without accepting twice`() = test { val proposed = paymentRequestRecord() diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt new file mode 100644 index 0000000000..076c74f7c4 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt @@ -0,0 +1,263 @@ +package to.bitkit.repositories + +import com.synonym.bitkitcore.LightningInvoice +import com.synonym.bitkitcore.NetworkType +import com.synonym.bitkitcore.Scanner +import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.PrivatePaymentResolutionState +import com.synonym.paykit.PrivatePaymentResolutionStatus +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +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.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.PrivatePaykitCacheData +import to.bitkit.data.PrivatePaykitCacheStore +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.ext.toHex +import to.bitkit.models.NodeLifecycleState +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset +import to.bitkit.services.CoreService +import to.bitkit.services.PaykitPreparedPrivateContactPayment +import to.bitkit.services.PaykitPrivateContactPaymentResolution +import to.bitkit.services.PaykitResolvedPaymentEndpoint +import to.bitkit.services.PaykitSdkService +import to.bitkit.services.PubkyService +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Clock +import kotlin.time.Instant + +class PrivatePaykitAllowancePaymentTest : BaseUnitTest(StandardTestDispatcher()) { + companion object { + private const val CONTACT_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private const val PRIVATE_ADDRESS = "bcrt1qs04g2ka4pr9s3mv73nu32tvfy7r3cxd27wkyu8" + private const val PRIVATE_BOLT11 = "lnbcrt1private" + private const val PRIVATE_LNURL = "lnurl1private" + private const val PAYMENT_APP_ID = "bitkit" + private const val NOW_SECONDS = 1_700_000_000L + private val PAYMENT_HASH = byteArrayOf(9, 9, 9) + } + + private val paykitSdkService = mock() + private val cacheStore = mock() + private val settingsStore = mock() + private val lightningRepo = mock() + private val publicPaykitRepo = mock() + private val coreService = mock() + private val clock = mock() + private lateinit var sut: PrivatePaykitRepo + + @Before + fun setUp() = test { + whenever(cacheStore.data).thenReturn(MutableStateFlow(PrivatePaykitCacheData())) + whenever(settingsStore.data).thenReturn(MutableStateFlow(SettingsData())) + whenever(lightningRepo.lightningState) + .thenReturn(MutableStateFlow(LightningState(nodeLifecycleState = NodeLifecycleState.Running))) + whenever(lightningRepo.getPayments()).thenReturn(Result.success(emptyList())) + whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(NOW_SECONDS)) + whenever(publicPaykitRepo.payableEndpoints(any())).thenAnswer { it.getArgument>(0) } + whenever(coreService.isAddressUsed(any())).thenReturn(false) + PublicPaykitRepo.lightningRouteHintsValidator = { true } + + sut = PrivatePaykitRepo( + ioDispatcher = testDispatcher, + paykitSdkService = paykitSdkService, + pubkyService = mock(), + cacheStore = cacheStore, + settingsStore = settingsStore, + addressReservationRepo = mock(), + lightningRepo = lightningRepo, + walletRepo = mock(), + publicPaykitRepo = publicPaykitRepo, + paymentRequestRepo = mock(), + usdtRepo = mock(), + coreService = coreService, + clock = clock, + ) + } + + @After + fun tearDown() { + PublicPaykitRepo.lightningRouteHintsValidator = null + } + + @Test + fun `allowance payment prefers an exact bolt11 invoice among accepted private endpoints`() = test { + stubResolution( + resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), + resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), + ) + stubInvoice(amountSats = 2_500uL) + val request = paymentRequest() + + val payment = sut.resolveAllowancePayment(request, eligible(MethodId.Bolt11, MethodId.P2wpkh)).getOrThrow() + + val invoiceEndpoint = PublicPaykitRepo.parseEndpoint(MethodId.Bolt11.rawValue, payload(PRIVATE_BOLT11)) + assertEquals( + PrivatePaykitAllowancePayment( + endpoint = checkNotNull(invoiceEndpoint).copy(appId = PAYMENT_APP_ID), + appId = PAYMENT_APP_ID, + context = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to PAYMENT_APP_ID), 7uL), + lightningPaymentHash = PAYMENT_HASH.toHex(), + lightningInvoiceHasAmount = true, + ), + payment, + ) + verify(paykitSdkService).prepareAndResolvePrivatePaymentRequest( + eq(CONTACT_KEY), + eq(request.paymentRequestId), + eq(null), + ) + } + + @Test + fun `allowance payment skips an invoice for another amount and falls back to on-chain`() = test { + stubResolution( + resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), + resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), + ) + stubInvoice(amountSats = 1_000uL) + + val payment = sut.resolveAllowancePayment(paymentRequest(), eligible(MethodId.Bolt11, MethodId.P2wpkh)) + .getOrThrow() + + assertEquals(MethodId.P2wpkh, payment?.endpoint?.methodId) + assertEquals(PRIVATE_ADDRESS, payment?.endpoint?.value) + assertNull(payment?.lightningPaymentHash) + } + + @Test + fun `amount-less invoice qualifies for the requested amount`() = test { + stubResolution(resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11)) + stubInvoice(amountSats = 0uL) + + val payment = sut.resolveAllowancePayment(paymentRequest(), eligible(MethodId.Bolt11)).getOrThrow() + + assertEquals(PRIVATE_BOLT11, payment?.endpoint?.value) + assertEquals(false, payment?.lightningInvoiceHasAmount) + } + + @Test + fun `allowance payment uses only endpoints the allowance and the request both accept`() = test { + stubResolution( + resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), + resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), + ) + stubInvoice(amountSats = 2_500uL) + val request = paymentRequest(accepted = eligible(MethodId.P2wpkh)) + + val payment = sut.resolveAllowancePayment(request, eligible(MethodId.Bolt11, MethodId.P2wpkh)).getOrThrow() + val none = sut.resolveAllowancePayment(request, eligible(MethodId.Bolt11)).getOrThrow() + + assertEquals(MethodId.P2wpkh, payment?.endpoint?.methodId) + assertNull(none) + } + + @Test + fun `lnurl and used addresses never qualify`() = test { + stubResolution( + resolvedEndpoint(MethodId.Lnurl, PRIVATE_LNURL), + resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), + ) + whenever(coreService.isAddressUsed(PRIVATE_ADDRESS)).thenReturn(true) + val request = paymentRequest(accepted = eligible(MethodId.Lnurl, MethodId.P2wpkh)) + + assertNull(sut.resolveAllowancePayment(request, eligible(MethodId.Lnurl, MethodId.P2wpkh)).getOrThrow()) + } + + @Test + fun `allowance payment needs a private payment list`() = test { + stubResolution(resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), version = null) + + assertNull(sut.resolveAllowancePayment(paymentRequest(), eligible(MethodId.P2wpkh)).getOrThrow()) + } + + @Test + fun `allowance payment waits for a payment list newer than the one already paid`() = test { + stubResolution( + resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), + version = null, + status = PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST, + ) + + val result = sut.resolveAllowancePayment(paymentRequest(), eligible(MethodId.Bolt11)) + + assertEquals(PaykitAllowanceError.PaymentListPending, result.exceptionOrNull()) + } + + @Test + fun `expired request is never resolved`() = test { + val expired = paymentRequest().copy(expiresAt = Instant.fromEpochSeconds(NOW_SECONDS - 1)) + + assertNull(sut.resolveAllowancePayment(expired, eligible(MethodId.Bolt11)).getOrThrow()) + verify(paykitSdkService, never()).prepareAndResolvePrivatePaymentRequest(any(), any(), anyOrNull()) + } + + private suspend fun stubResolution( + vararg endpoints: PaykitResolvedPaymentEndpoint, + version: ULong? = 7uL, + status: PrivatePaymentResolutionStatus = PrivatePaymentResolutionStatus.PAYABLE, + ) { + whenever(paykitSdkService.prepareAndResolvePrivatePaymentRequest(any(), any(), anyOrNull())) + .thenReturn( + PaykitPreparedPrivateContactPayment( + resolution = PaykitPrivateContactPaymentResolution( + status = status, + state = PrivatePaymentResolutionState.AVAILABLE, + privatePaymentListVersion = version, + payableEndpoints = endpoints.toList(), + ), + linkState = LinkedPeerState.LINKED, + ), + ) + } + + private suspend fun stubInvoice(amountSats: ULong) { + whenever(coreService.decode(PRIVATE_BOLT11)).thenReturn( + Scanner.Lightning( + LightningInvoice( + bolt11 = PRIVATE_BOLT11, + paymentHash = PAYMENT_HASH, + amountSatoshis = amountSats, + timestampSeconds = NOW_SECONDS.toULong(), + expirySeconds = 86_400uL, + isExpired = false, + description = "", + networkType = NetworkType.REGTEST, + payeeNodeId = null, + ), + ), + ) + } + + private fun eligible(vararg methods: MethodId) = methods.map { it.rawValue } + + private fun payload(value: String) = PublicPaykitRepo.serializePayload(value) + + private fun resolvedEndpoint(methodId: MethodId, value: String) = PaykitResolvedPaymentEndpoint( + appId = PAYMENT_APP_ID, + identifier = methodId.rawValue, + payload = payload(value), + ) + + private fun paymentRequest(accepted: List = eligible(MethodId.Bolt11, MethodId.P2wpkh)) = + PaykitPaymentRequest( + paymentRequestId = "request-id", + counterparty = CONTACT_KEY, + amount = PaykitAmount(PaykitAsset.BTC, 2_500uL), + paymentReference = "fixture-reference", + expiresAt = Instant.fromEpochSeconds(NOW_SECONDS + 60), + acceptedPaymentEndpointIdentifiers = accepted, + ) +} diff --git a/app/src/test/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModelTest.kt new file mode 100644 index 0000000000..f182503edb --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModelTest.kt @@ -0,0 +1,305 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.ui.screens.subscriptions + +import android.content.Context +import com.synonym.paykit.AllowanceLifecycleState +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.R +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.models.ConvertedAmount +import to.bitkit.models.PubkyProfile +import to.bitkit.models.USD +import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.CurrencyState +import to.bitkit.repositories.PaykitAllowance +import to.bitkit.repositories.PaykitAllowanceEntry +import to.bitkit.repositories.PaykitAllowanceError +import to.bitkit.repositories.PaykitAllowanceLimits +import to.bitkit.repositories.PaykitAllowanceRepo +import to.bitkit.repositories.PaykitPaymentRequest +import to.bitkit.repositories.PaykitPaymentRequestRepo +import to.bitkit.repositories.PaykitPaymentRequestTarget +import to.bitkit.repositories.PubkyRepo +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.ServiceError +import java.math.BigDecimal +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +class AllowancesViewModelTest : BaseUnitTest() { + companion object { + private const val LEO_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private const val MIA_KEY = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + + /** Sats per US dollar in these tests, so 4 990 sats is $4.99. */ + private val SATS_PER_USD = BigDecimal(1_000) + } + + private val context: Context = mock() + private val allowanceRepo: PaykitAllowanceRepo = mock() + private val paymentRequestRepo: PaykitPaymentRequestRepo = mock() + private val pubkyRepo: PubkyRepo = mock() + private val currencyRepo: CurrencyRepo = mock() + private val settingsStore: SettingsStore = mock() + private val settings = MutableStateFlow(SettingsData()) + private val clock = object : Clock { + override fun now() = Instant.parse("2027-01-15T08:00:00Z") + } + + private val entries = MutableStateFlow>(emptyList()) + private val autoPaidSats = MutableStateFlow>(emptyMap()) + private val contacts = MutableStateFlow(listOf(profile(LEO_KEY, "Leo"), profile(MIA_KEY, "Mia"))) + private val targets = MutableStateFlow>(emptyList()) + + private lateinit var sut: AllowancesViewModel + + @Before + fun setUp() { + whenever(allowanceRepo.entries).thenReturn(entries) + whenever(settingsStore.data).thenReturn(settings) + whenever(allowanceRepo.autoPaidSats).thenReturn(autoPaidSats) + whenever(pubkyRepo.contacts).thenReturn(contacts) + whenever(paymentRequestRepo.eligibleTargets).thenReturn(targets) + whenever(paymentRequestRepo.paymentRequestHistory) + .thenReturn(MutableStateFlow(emptyList())) + whenever(currencyRepo.currencyState).thenReturn(MutableStateFlow(CurrencyState())) + whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenAnswer { + val sats = it.getArgument(0) + ConvertedAmount( + value = BigDecimal(sats).divide(SATS_PER_USD), + formatted = "", + symbol = "$", + currency = USD, + flag = "", + sats = sats, + ) + } + whenever(currencyRepo.convertFiatToSats(any(), anyOrNull())).thenAnswer { + it.getArgument(0).multiply(SATS_PER_USD).toLong().toULong() + } + whenever(context.getString(any())).thenReturn("") + mapOf( + R.string.subscriptions__allowance_status_active to "Active", + R.string.subscriptions__allowance_status_ended to "Ended", + R.string.subscriptions__allowance_status_waiting to "Waiting for an answer", + R.string.subscriptions__allowance_per_payment_short to "{amount} a payment", + R.string.subscriptions__allowance_paid_automatically to "{amount} paid automatically", + R.string.subscriptions__allowance_up_to to "Up to {amount}", + R.string.subscriptions__allowance_error_not_linked to "This contact is not connected yet.", + R.string.common__error to "Error", + ).forEach { (id, text) -> whenever(context.getString(id)).thenReturn(text) } + sut = AllowancesViewModel( + context = context, + allowanceRepo = allowanceRepo, + paymentRequestRepo = paymentRequestRepo, + pubkyRepo = pubkyRepo, + currencyRepo = currencyRepo, + settingsStore = settingsStore, + clock = clock, + ) + } + + @Test + fun `limits known only in sats round back to whole dollars`() = test { + entries.value = listOf(entry(perPaymentSats = 4_990uL, monthlySats = 49_900uL, limits = null)) + + val allowance = loadedState().allowances.single() + + // The same short form the payer's row shows from its own whole-dollar limits. + assertEquals("Active · $5 a payment", allowance.subtitle) + assertEquals("Up to $5.00", allowance.perPaymentUpTo) + assertEquals("50.00", allowance.monthlyAmount) + } + + @Test + fun `limits picked on this wallet show the chosen dollar stops`() = test { + val limits = PaykitAllowanceLimits( + perPaymentUsd = 5, + monthlyUsd = 50, + perPaymentSats = 4_900uL, + monthlySats = 49_000uL, + ) + entries.value = listOf(entry(perPaymentSats = 4_900uL, monthlySats = 49_000uL, limits = limits)) + + val allowance = loadedState().allowances.single() + + assertEquals("Active · $5 a payment", allowance.subtitle) + assertEquals("50.00", allowance.monthlyAmount) + } + + @Test + fun `an ended allowance shows what it paid automatically`() = test { + entries.value = listOf(entry(state = AllowanceLifecycleState.ENDED)) + autoPaidSats.value = mapOf("entry-1" to 2_000uL) + + val allowance = loadedState().allowances.single() + + assertEquals("Ended · $2.00 paid automatically", allowance.subtitle) + assertEquals("$2.00", allowance.paidSoFar) + assertTrue(allowance.isInactive) + } + + @Test + fun `contact picker lists only contacts that can receive payment requests`() = test { + targets.value = listOf(PaykitPaymentRequestTarget(LEO_KEY)) + + assertEquals(listOf("Leo"), loadedState().contacts.map { it.name }) + } + + @Test + fun `a limit known only in sats below a dollar keeps its cents`() = test { + entries.value = listOf(entry(perPaymentSats = 500uL, monthlySats = 49_900uL, limits = null)) + + assertEquals("Active · $0.50 a payment", loadedState().allowances.single().subtitle) + } + + @Test + fun `saving converts the chosen dollar stops to sats once`() = test { + val limits = PaykitAllowanceLimits( + perPaymentUsd = 5, + monthlyUsd = 100, + perPaymentSats = 5_000uL, + monthlySats = 100_000uL, + ) + whenever(allowanceRepo.propose(LEO_KEY, limits)).thenReturn(Result.success(Unit)) + var saved = false + + sut.propose(profile(LEO_KEY, "Leo"), perPaymentUsd = 5, monthlyUsd = 100) { saved = true } + + verify(allowanceRepo).propose(LEO_KEY, limits) + assertTrue(saved) + } + + @Test + fun `saving without a dollar rate proposes nothing`() = test { + whenever(currencyRepo.convertFiatToSats(any(), anyOrNull())) + .thenReturn(Result.failure(ServiceError.CurrencyRateUnavailable())) + + sut.propose(profile(LEO_KEY, "Leo"), perPaymentUsd = 5, monthlyUsd = 100) {} + + verify(allowanceRepo, never()).propose(any(), any()) + } + + @Test + fun `a failed save keeps the sheet open and frees the button`() = test { + whenever(allowanceRepo.propose(any(), any())).thenReturn(Result.failure(PaykitAllowanceError.ContactNotLinked)) + var saved = false + + sut.propose(profile(LEO_KEY, "Leo"), perPaymentUsd = 5, monthlyUsd = 100) { saved = true } + + assertEquals(false, saved) + assertEquals(false, loadedState().isWorking) + } + + @Test + fun `saving asks for the pin first when payments require it`() = test { + settings.value = SettingsData(isPinEnabled = true, isPinForPaymentsEnabled = true, isBiometricEnabled = true) + whenever(allowanceRepo.propose(any(), any())).thenReturn(Result.success(Unit)) + var saved = false + + sut.propose(profile(LEO_KEY, "Leo"), perPaymentUsd = 5, monthlyUsd = 100) { saved = true } + + assertEquals(AllowanceAuthorization(useBiometrics = true), loadedState().authorization) + verify(allowanceRepo, never()).propose(any(), any()) + + sut.onAuthorized() + + verify(allowanceRepo).propose(eq(LEO_KEY), any()) + assertTrue(saved) + assertNull(loadedState().authorization) + } + + @Test + fun `a cancelled check ends nothing`() = test { + settings.value = SettingsData(isPinEnabled = true, isPinForPaymentsEnabled = true) + entries.value = listOf(entry()) + + sut.end("entry-1") {} + assertEquals(AllowanceAuthorization(useBiometrics = false), loadedState().authorization) + sut.onAuthorizationCancelled() + + verify(allowanceRepo, never()).end(any()) + assertNull(loadedState().authorization) + } + + @Test + fun `the payee answers and the payer declines without a check`() = test { + settings.value = SettingsData(isPinEnabled = true, isPinForPaymentsEnabled = true) + entries.value = listOf(entry(role = PaykitAllowance.Role.ALLOWEE)) + whenever(allowanceRepo.accept(any())).thenReturn(Result.success(Unit)) + whenever(allowanceRepo.reject(any())).thenReturn(Result.success(Unit)) + + sut.accept("entry-1") {} + sut.decline("entry-1") {} + + verify(allowanceRepo).accept("entry-1") + verify(allowanceRepo).reject("entry-1") + assertNull(loadedState().authorization) + } + + @Test + fun `nothing is asked when payments need no pin`() = test { + settings.value = SettingsData(isPinEnabled = true, isPinForPaymentsEnabled = false) + entries.value = listOf(entry()) + whenever(allowanceRepo.end(any())).thenReturn(Result.success(Unit)) + + sut.end("entry-1") {} + + verify(allowanceRepo).end("entry-1") + assertNull(loadedState().authorization) + } + + private fun TestScope.loadedState(): AllowancesUiState { + backgroundScope.launch { sut.uiState.collect {} } + runCurrent() + return sut.uiState.value.also { assertTrue(it.isLoaded) } + } + + private fun entry( + state: AllowanceLifecycleState = AllowanceLifecycleState.ACCEPTED, + perPaymentSats: ULong = 5_000uL, + monthlySats: ULong = 50_000uL, + limits: PaykitAllowanceLimits? = null, + role: PaykitAllowance.Role = PaykitAllowance.Role.ALLOWER, + ) = PaykitAllowanceEntry( + id = "entry-1", + allowances = listOf( + PaykitAllowance( + id = PaykitAllowance.Id(LEO_KEY, "allowance-1"), + role = role, + lifecycleState = state, + isProposedByMe = true, + perPaymentMaxSats = perPaymentSats, + monthlyLimitSats = monthlySats, + monthlyAnchor = null, + ), + ), + limits = limits, + ) + + private fun profile(publicKey: String, name: String) = PubkyProfile.forDisplay( + publicKey = publicKey, + name = name, + imageUrl = null, + ) +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 94257e20d6..9da92d63a6 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -72,11 +72,13 @@ import org.lightningdevkit.ldknode.TransactionDetails import org.mockito.Mockito import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeast import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.check import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq @@ -157,6 +159,7 @@ import to.bitkit.repositories.OnchainSendBlockedError import to.bitkit.repositories.OnchainSendEvidence import to.bitkit.repositories.OnchainSendNotDispatchedError import to.bitkit.repositories.OnchainSendOutcome +import to.bitkit.repositories.PaykitAllowanceRepo import to.bitkit.repositories.PaykitBillingPeriod import to.bitkit.repositories.PaykitOnchainPaymentProofResolution import to.bitkit.repositories.PaykitPaymentProofKind @@ -280,6 +283,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val privatePaykitRepo = mock() private val paymentSubmissionActive = MutableStateFlow(false) private val paykitPaymentRequestRepo = mock() + private val paykitAllowanceRepo = mock() + private val noCoverageRelease: () -> Unit = {} private val paykitPaymentProofRepo = mock() private val paykitPaymentRequestDiagnostics = mock() private val usdtRepo = mock() @@ -702,6 +707,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.isExpired(any())).thenReturn(false) whenever(paykitPaymentRequestRepo.isProcessing(any())).thenReturn(false) whenever(paykitPaymentProofRepo.onchainPaymentResolutions).thenReturn(onchainPaymentResolutions) + whenever(paykitAllowanceRepo.events).thenReturn(MutableSharedFlow()) + whenever { paykitAllowanceRepo.refresh() }.thenReturn(Result.success(Unit)) + whenever { paykitAllowanceRepo.beginManualPayment(any(), any()) }.thenReturn(Result.success(null)) + whenever { paykitAllowanceRepo.processIncomingRequests(any()) }.thenReturn(false) + whenever { paykitAllowanceRepo.isAutomaticallyHandling(any()) }.thenReturn(false) + whenever(paykitAllowanceRepo.isUpdating).thenReturn(false) + doReturn(noCoverageRelease).whenever(paykitAllowanceRepo).beginCoverageUpdate() whenever { paykitPaymentProofRepo.captureOriginalOnchainPayer(any()) } .thenReturn(Result.success("pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg")) whenever { paykitPaymentProofRepo.verifyOriginalOnchainPayer(any(), any()) }.thenReturn(Result.success(Unit)) @@ -711,7 +723,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { any(), any(), any(), - anyOrNull() + anyOrNull(), + anyOrNull(), ) }.thenReturn(Result.success(Unit)) whenever(paykitPaymentProofRepo.paymentRequestStateChanges(any())).thenReturn(proofStateVersion.drop(1).map { }) @@ -827,6 +840,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { privatePaykitRepo = privatePaykitRepo, contactPaymentSettingsRepo = contactPaymentSettingsRepo, paykitPaymentRequestRepo = paykitPaymentRequestRepo, + paykitAllowanceRepo = paykitAllowanceRepo, paykitPaymentProofRepo = paykitPaymentProofRepo, paykitPaymentRequestDiagnostics = paykitPaymentRequestDiagnostics, refreshContactPaykitLink = refreshContactPaykitLink, @@ -1842,7 +1856,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(signed.miningFeeSats.toLong(), sut.sendUiState.value.onchainFeeUi.sats) verify(privatePaykitRepo, never()).beginPaymentRequest(request) verify(paykitPaymentRequestRepo, never()).accept(any()) - verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any(), anyOrNull()) + verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any(), anyOrNull(), anyOrNull()) verify(hwWalletRepo, never()).signFunding(any(), any()) assertTrue(sut.sendUiState.value.isRetainedHardwarePayment) sut.resetSendState(hardwareWalletId = null) @@ -1859,7 +1873,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertTrue(sut.sendUiState.value.isRetainedHardwarePayment) assertEquals(request.amount.atomic, sut.sendUiState.value.amount) assertEquals(Instant.parse(quote.expiresAt), sut.hardwarePaymentDeadlineAt) - verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any(), anyOrNull()) + verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any(), anyOrNull(), anyOrNull()) } @Test @@ -1893,7 +1907,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { cancelAndIgnoreRemainingEvents() } verify(privatePaykitRepo, never()).beginPaymentRequest(any()) - verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any(), anyOrNull()) + verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any(), anyOrNull(), anyOrNull()) verify(hwWalletRepo, never()).signFunding(any(), any()) } @@ -6350,6 +6364,196 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(pendingPaymentRepo, never()).resolve(any()) } + @Test + fun `coverage is held from the request refresh until the allowances are refreshed`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + sut.setIsAuthenticated(true) + runCurrent() + var held = false + val heldDuring = mutableListOf() + doAnswer { + held = true + val release: () -> Unit = { held = false } + release + }.whenever(paykitAllowanceRepo).beginCoverageUpdate() + whenever { paykitPaymentRequestRepo.refresh(any(), any()) }.doSuspendableAnswer { + heldDuring += "requests:$held" + Result.success(Unit) + } + whenever { paykitAllowanceRepo.refresh() }.doSuspendableAnswer { + heldDuring += "allowances:$held" + Result.success(Unit) + } + + emitNodeEvent( + Event.PaymentSuccessful( + paymentId = "payment_id", + paymentHash = "unrelated_hash", + paymentPreimage = "preimage", + feePaidMsat = 10uL, + ), + ) + runCurrent() + + assertTrue(heldDuring.isNotEmpty()) + assertTrue(heldDuring.all { it.endsWith(":true") }) + assertFalse(held) + } + + @Test + fun `a request an allowance may cover is held while allowances update`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + sut.setIsAuthenticated(true) + runCurrent() + val request = paymentRequest() + whenever(paykitAllowanceRepo.isUpdating).thenReturn(true) + whenever(paykitAllowanceRepo.mayCover(request)).thenReturn(true) + + pendingPaykitPaymentRequests.value = listOf(request) + runCurrent() + + verify(privatePaykitRepo, never()).beginPaymentRequest(request) + } + + @Test + fun `the allowance pass never takes a request whose manual sheet is being prepared`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + sut.setIsAuthenticated(true) + runCurrent() + val request = paymentRequest() + val resolution = CompletableDeferred>() + whenever(privatePaykitRepo.beginPaymentRequest(request)).doSuspendableAnswer { resolution.await() } + pendingPaykitPaymentRequests.value = listOf(request) + runCurrent() + verify(privatePaykitRepo).beginPaymentRequest(request) + clearInvocations(paykitAllowanceRepo) + + emitNodeEvent( + Event.PaymentSuccessful( + paymentId = "payment_id", + paymentHash = "unrelated_hash", + paymentPreimage = "preimage", + feePaidMsat = 10uL, + ), + ) + runCurrent() + + val passes = argumentCaptor>() + verify(paykitAllowanceRepo, atLeastOnce()).processIncomingRequests(passes.capture()) + assertTrue(passes.allValues.all { pass -> pass.none { it.id == request.id } }) + resolution.complete(Result.success(PublicPaykitPaymentResult.NotOpened)) + runCurrent() + } + + @Test + fun `a pending covered request does not make presentation start over`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + sut.setIsAuthenticated(true) + runCurrent() + val covered = paymentRequest().copy(paymentRequestId = "covered") + // A subscription period request has no preparation, so a pending link keeps it listed. + val manual = paymentRequest().copy( + paymentRequestId = "manual", + billingPeriod = PaykitBillingPeriod(Clock.System.now(), Clock.System.now() + 60.seconds), + ) + whenever { paykitAllowanceRepo.isAutomaticallyHandling(covered) }.thenReturn(true) + whenever(privatePaykitRepo.beginPaymentRequest(manual)) + .thenReturn(Result.success(PublicPaykitPaymentResult.PrivateLinkPending)) + + pendingPaykitPaymentRequests.value = listOf(covered, manual) + runCurrent() + + verify(privatePaykitRepo, never()).beginPaymentRequest(covered) + verify(privatePaykitRepo, times(1)).beginPaymentRequest(manual) + } + + @Test + fun `automatic allowance payment success never reaches the send flow`() = test { + val paymentHash = "allowance_ok" + whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(false) + whenever(paykitAllowanceRepo.isAutomaticPayment(paymentHash)).thenReturn(true) + + emitNodeEvent( + Event.PaymentSuccessful( + paymentId = "payment_id", + paymentHash = paymentHash, + paymentPreimage = "preimage", + feePaidMsat = 10uL, + ), + ) + advanceUntilIdle() + + verify(paykitPaymentProofRepo).completeLightningPayment(paymentHash, "preimage") + verify(activityRepo, never()).findActivityByPaymentId(any(), any(), any(), any()) + verify(pendingPaymentRepo, never()).resolve(any()) + } + + @Test + fun `automatic allowance payment failure shows no send error`() = test { + val paymentHash = "allowance_failed" + whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(false) + whenever(paykitAllowanceRepo.isAutomaticPayment(paymentHash)).thenReturn(true) + advanceUntilIdle() + clearInvocations(toastManager) + + emitNodeEvent( + Event.PaymentFailed( + paymentId = "payment_id", + paymentHash = paymentHash, + reason = PaymentFailureReason.ROUTE_NOT_FOUND, + ), + ) + advanceUntilIdle() + + verify(paykitPaymentProofRepo).failLightningPayment(paymentHash) + verify(toastManager, never()).enqueue(any()) + } + + @Test + fun `manual retry of a failed automatic invoice still resolves as pending`() = test { + val paymentHash = "allowance_retry" + whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(true) + whenever(pendingPaymentRepo.isActive(paymentHash)).thenReturn(false) + whenever(paykitAllowanceRepo.isAutomaticPayment(paymentHash)).thenReturn(true) + + emitNodeEvent( + Event.PaymentSuccessful( + paymentId = "payment_id", + paymentHash = paymentHash, + paymentPreimage = "preimage", + feePaidMsat = 10uL, + ), + ) + advanceUntilIdle() + + verify(pendingPaymentRepo).resolve(PendingPaymentResolution.Success(paymentHash)) + } + + @Test + fun `opening a request an allowance is paying shows a notice and restores the requests sheet`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + sut.setIsAuthenticated(true) + runCurrent() + val request = paymentRequest() + whenever { paykitAllowanceRepo.isAutomaticallyHandling(request) }.thenReturn(true) + pendingPaykitPaymentRequests.value = listOf(request) + runCurrent() + sut.showSheet(Sheet.PaymentRequests) + clearInvocations(toastManager) + + sut.openIncomingPaymentRequest(request.id) + runCurrent() + + assertEquals(Sheet.PaymentRequests, sut.currentSheet.value) + verify(toastManager).enqueue(argThat { testTag == "AllowancePaymentInProgressToast" }) + verify(privatePaykitRepo, never()).beginPaymentRequest(request) + } + @Test fun `PaymentSuccessful clears disk reservation when not pending`() = test { val paymentHash = "restart_ok" @@ -13981,7 +14185,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertFalse(sut.sendUiState.value.shouldAutomaticallyPay) assertFalse(sut.sendUiState.value.shouldConfirmPay) verify(paykitPaymentRequestRepo, never()).accept(any()) - verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any(), anyOrNull()) + verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any(), anyOrNull(), anyOrNull()) } @Test @@ -14034,7 +14238,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } verify(paykitPaymentRequestRepo, never()).accept(request) - verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any(), anyOrNull()) + verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any(), anyOrNull(), anyOrNull()) } @Test @@ -14092,7 +14296,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } verify(paykitPaymentRequestRepo, never()).accept(any()) - verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any(), anyOrNull()) + verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any(), anyOrNull(), anyOrNull()) } @Test @@ -14160,6 +14364,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { eq("bitkit"), eq(PaykitPaymentProofKind.Onchain), eq(request.payment(MethodId.P2wpkh, Clock.System.now())), + isNull(), ) verify(paykitPaymentRequestRepo).accept(request) verify(paykitPaymentProofRepo).completeOnchainPayment(any(), eq("txid"), any(), any(), anyOrNull(), anyOrNull()) @@ -14442,7 +14647,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() assertTrue(sut.sendUiState.value.paykitUsesUsdt) assertFalse(sut.prepareHardwareContactPayment()) - verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any(), anyOrNull()) + verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any(), anyOrNull(), anyOrNull()) } @Test @@ -14466,7 +14671,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), ) verify(paykitPaymentRequestRepo, never()).accept(any()) - verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any(), anyOrNull()) + verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any(), anyOrNull(), anyOrNull()) } private fun privatePaymentContext(version: ULong?) = PrivatePaykitPaymentContext( paymentAppsByEndpoint = mapOf( diff --git a/changelog.d/next/1340.added.md b/changelog.d/next/1340.added.md new file mode 100644 index 0000000000..6e89aeb684 --- /dev/null +++ b/changelog.d/next/1340.added.md @@ -0,0 +1 @@ +Allowances: set a per-payment and a monthly limit for a Paykit contact so their payment requests within the limits are paid automatically. diff --git a/docs/screens-map.md b/docs/screens-map.md index 4ababf187e..bfde34c946 100644 --- a/docs/screens-map.md +++ b/docs/screens-map.md @@ -121,6 +121,7 @@ Frame names are stable across handoff iterations; node ids are not, so the map l | Android | Figma | | - | - | +| AllowancesScreen.kt | `todo` | | CreateSubscriptionScreen.kt | Subscriptions › Create Subscription / Choose Subscription Recipient / Sent Subscription Proposal | | SubscriptionsScreen.kt | Subscriptions › Subscriptions Intro / Subscriptions overview | diff --git a/journeys/allowances/README.md b/journeys/allowances/README.md new file mode 100644 index 0000000000..e362b0e19e --- /dev/null +++ b/journeys/allowances/README.md @@ -0,0 +1,55 @@ +# Allowances journeys + +Cover the Paykit allowance lifecycle between two Bitkit instances: the payer sets an allowance for a +contact, the payee accepts it, requests within the limits are paid without asking, a request above a +limit falls back to the normal Payment Request sheet, and either side ends it. The restart journey +pins the one rule that must never break: a payment interrupted mid-flight is never paid twice. + +## Setup + +Run Bitkit against regtest with Paykit UI enabled on two instances that have each other saved as +contacts and linked over Paykit, exactly as for +[`../subscriptions/README.md`](../subscriptions/README.md). One instance plays the payer (the one +that sets the allowance), the other the payee (the one that sends requests). Automatic payments go +over Lightning only, so the payer needs a spending balance above 50,000 sats with a usable channel, +and the payee needs receiving capacity; fund both through the staging LSP. + +Grant the payer notification permission first (`adb shell pm grant to.bitkit.dev +android.permission.POST_NOTIFICATIONS`): the "Payment Executed" and "Limit Reached" events post a +system notification when they can, and fall back to a toast that `android layout` cannot see. + +The journeys set $5 a payment and $50 a month, the second stop on each slider, and the cap journey +sets $5 a payment and $10 a month, the first monthly stop. Requests are one-time Payment Requests +created from the Payments tab of the payee, in the fiat unit. Dollar amounts on screen are converted +at the current rate, so a sats amount next to them will differ between runs. + +## Reference evidence + +The source run drove every journey on 2026-09-24 across two Android 16 emulators against the +staging regtest LSP, with Paykit built from pubky/paykit-rs#161. A $2 and a $4 request were paid +about two seconds after arriving, a $20 request asked, the third $4 request on a $10 monthly cap +raised Limit Reached, ending the allowance from either side stopped automatic payments, and a payer +killed right after handing the payment to the node never paid twice after relaunch. + +## Identifiers used + +- Tab: `Tab-allowances`; empty state `AllowancesEmpty`; footer button `AllowanceAdd` +- Contact picker: `AllowanceContact-` +- Set sheet: `SetAllowance`, sliders `AllowancePerPayment` and `AllowanceMonthly` with stops + `AllowancePerPaymentStop-` and `AllowanceMonthlyStop-`, `AllowanceSummary`, + `AllowanceSave` +- List row: `AllowanceRow-` with its status line `AllowanceRowStatus`; the entry id is the payer's grant id, or the contact key and allowance id on the payee +- Review sheet (payee): `AllowanceReview`, `AllowanceCounterparty`, `AllowancePerPaymentValue`, + `AllowanceMonthlyValue`, `AllowanceAccept`, `AllowanceDecline` +- Detail sheet: `AllowanceDetail`, `AllowancePaidSoFar`, `AllowanceDetailStatus` +- Payments tab: `Tab-payments`, `PaymentRequestCreate`, `PaymentRequestRow-` + whose subtitle ends with "· Auto-paid" for an automatic payment +- Incoming request: `PaymentRequestsBell`, `PaymentRequestsSheet` + +A payer who requires a PIN for payments sees the PIN pad (or biometrics) over the sheet when saving or ending +an allowance, and when accepting an allowance a payee asked for; the payee's answers and automatic payments never +ask. `pin-gates-allowance-changes.xml` covers it. + +The review sheet on the payee opens on its own about a second after the proposal arrives; no tap is +needed to reach it. `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 missing. diff --git a/journeys/allowances/above-limit-asks.xml b/journeys/allowances/above-limit-asks.xml new file mode 100644 index 0000000000..598cfe4932 --- /dev/null +++ b/journeys/allowances/above-limit-asks.xml @@ -0,0 +1,19 @@ + + + A request above the per-payment limit is never paid automatically: it arrives as an ordinary + Payment Request that the payer pays by swiping, and a manual payment does not count toward the + amount paid automatically. Requires the Active $5 a payment allowance from set-and-accept.xml + and a payer balance above the request. + + + Launch the E2E Bitkit app on both instances with the $5 a payment, $50 a month allowance Active and at least one automatic payment made, per auto-pay-under-limit.xml + On the payee instance, open Subscriptions, the Payments tab (testTag "Tab-payments"), tap Request Payment (testTag "PaymentRequestCreate") and send the payer a one-time Payment Request for $20 with the note "Artpack" + Switch to the payer instance + Verify the Payment Request sheet (testTag "PaymentRequestsSheet") opens for $20.00 from the payee, or the bell (testTag "PaymentRequestsBell") shows one pending request, and that nothing was sent automatically + Verify no "Payment Executed" notification was posted for this request + Pay it from the sheet (testTag "PaymentRequestPay-<paymentRequestId>") and complete Swipe To Pay + Verify Bitcoin Sent shows about $20.00 + Open Subscriptions, tap the Payments tab and verify the "Artpack" row is listed without "· Auto-paid" in its subtitle + Tap the Allowances tab, tap the payee's row and verify PAID AUTOMATICALLY (testTag "AllowancePaidSoFar") still shows only the automatic payments, not the $20 + + diff --git a/journeys/allowances/auto-pay-under-limit.xml b/journeys/allowances/auto-pay-under-limit.xml new file mode 100644 index 0000000000..e539f7e258 --- /dev/null +++ b/journeys/allowances/auto-pay-under-limit.xml @@ -0,0 +1,22 @@ + + + A request within both limits is paid without the payer seeing a sheet. The payer only gets a + "Payment Executed" notification, the payee receives the payment, and the payer's Payments tab and + allowance detail both account for it. Requires the Active allowance from set-and-accept.xml and + notification permission granted on the payer. + + + Launch the E2E Bitkit app on both instances with the $5 a payment, $50 a month allowance from set-and-accept.xml Active, and the payer's notification permission granted + On the payee instance, open the drawer menu, tap Subscriptions, tap the Payments tab (testTag "Tab-payments") and tap Request Payment (testTag "PaymentRequestCreate") + Send the payer a one-time Payment Request for $2 with the note "Devlog" + Switch to the payer instance, with Bitkit in the foreground on the home screen + Verify that within about five seconds no Payment Request sheet opens and the bell (testTag "PaymentRequestsBell") shows no pending request + Verify a "Payment Executed" notification with the text "Auto-pay sent $2.00 to <payee>" is posted, reading it back with `adb shell dumpsys notification --noredact`; without notification permission it is a toast that only a screenshot catches + Switch to the payee instance and verify the payment arrives, either as the Received Instant Bitcoin sheet or as a $2.00 "Received from <payer>" row in Activity + Switch to the payer instance, open the drawer menu, tap Subscriptions and tap the Payments tab + Verify the "Devlog" row (testTag "PaymentRequestRow-<paymentRequestId>") is listed with a subtitle ending in "· Auto-paid" + Tap the Allowances tab, then tap the payee's row + Verify the detail sheet (testTag "AllowanceDetail") shows PAID AUTOMATICALLY "$2.00" (testTag "AllowancePaidSoFar") and the explanation "Requests within these limits are paid automatically. Anything above them asks you first." (testTag "AllowanceDetailStatus") + Open Activity from the home screen and verify the newest row reads "Sent to <payee>" for $2.00 + + diff --git a/journeys/allowances/end-stops-auto-pay.xml b/journeys/allowances/end-stops-auto-pay.xml new file mode 100644 index 0000000000..65a0221380 --- /dev/null +++ b/journeys/allowances/end-stops-auto-pay.xml @@ -0,0 +1,26 @@ + + + Either side can end an allowance from its detail sheet, and from then on every request asks + again. The first half ends it on the payer; the second half sets a fresh allowance and ends it + on the payee. In both cases the payer's row keeps the amount that was paid automatically, and the + next request waits in the bell as an ordinary Payment Request. + + + Launch the E2E Bitkit app on both instances with an Active allowance that has paid at least one request automatically, per auto-pay-under-limit.xml + On the payer instance, open the drawer menu, tap Subscriptions, tap the Allowances tab and tap the payee's row + Verify the detail sheet (testTag "AllowanceDetail") ends with a swipe control reading "Swipe To End Allowance" + Swipe the control to the end + Verify the sheet dismisses and the row's status line (testTag "AllowanceRowStatus") reads "Ended · " followed by the amount paid automatically, such as "Ended · $2.00 paid automatically", with the row dimmed + Tap the row and verify the detail explanation (testTag "AllowanceDetailStatus") reads "This allowance has ended. Every request asks again." with no swipe control + On the payee instance, send the payer a one-time Payment Request for $2 with the note "AfterEnd" + On the payer instance, verify it is not paid: no "Payment Executed" notification, and the request waits in the bell (testTag "PaymentRequestsBell") as an ordinary Payment Request + Dismiss that request + On the payee instance, open Subscriptions and the Allowances tab, and verify the payer's row reads "Ended" + Set and accept a fresh $5 a payment, $50 a month allowance between the same two instances, per set-and-accept.xml + On the payee instance, tap the Active row, verify the detail ends with "Swipe To End Allowance", and swipe it to the end + Verify the payee's row reads "Ended" + On the payer instance, verify its row for that allowance reads "Ended"; tap it and verify the detail sheet shows PAID AUTOMATICALLY "$0.00" (testTag "AllowancePaidSoFar") + On the payee instance, send the payer a one-time Payment Request for $2 with the note "AfterPayeeEnd" + On the payer instance, verify it is not paid and waits in the bell as an ordinary Payment Request, then dismiss it + + diff --git a/journeys/allowances/monthly-cap-reached.xml b/journeys/allowances/monthly-cap-reached.xml new file mode 100644 index 0000000000..4f6cd032c2 --- /dev/null +++ b/journeys/allowances/monthly-cap-reached.xml @@ -0,0 +1,22 @@ + + + Requests keep paying themselves until the month's allowance is used up; the first request that + would exceed it is left to the payer with a "Limit Reached" notification and the ordinary Payment + Request sheet. The journey uses a $5 a payment, $10 a month allowance so two $4 requests fit and + the third does not. The second request can take a few extra seconds: after a payment the payee + publishes a new private payment list, and the payer waits for it rather than asking. + + + Launch the E2E Bitkit app on both instances with no Active allowance between them and the payer's notification permission granted + On the payer instance, set an allowance for the payee as in set-and-accept.xml, but with the $5 stop (testTag "AllowancePerPaymentStop-1") and the $10 stop (testTag "AllowanceMonthlyStop-0"), and have the payee accept it + Verify the payer's row reads "Active · $5 a payment" with "$ 10.00" over "Monthly limit" + On the payee instance, send the payer a one-time Payment Request for $4 with the note "Cap1" + On the payer instance, verify it is paid without a sheet and a "Payment Executed" notification reads "Auto-pay sent $4.00 to <payee>" + On the payee instance, wait for the payment to arrive, then send a second $4 request with the note "Cap2" + On the payer instance, verify it is paid without a sheet within about fifteen seconds, and a second "Payment Executed" notification is posted + On the payee instance, send a third $4 request with the note "Cap3" + On the payer instance, verify a "Limit Reached" notification reads "A $4.00 request from <payee> is above your allowance. Review it to pay." and the Payment Request sheet opens for $4.00 + Dismiss the request (testTag "PaymentRequestDismiss-<paymentRequestId>") + Open Subscriptions, tap the Allowances tab, tap the payee's row and verify PAID AUTOMATICALLY (testTag "AllowancePaidSoFar") reads "$8.00" + + diff --git a/journeys/allowances/pin-gates-allowance-changes.xml b/journeys/allowances/pin-gates-allowance-changes.xml new file mode 100644 index 0000000000..b565565328 --- /dev/null +++ b/journeys/allowances/pin-gates-allowance-changes.xml @@ -0,0 +1,26 @@ + + + When the payer requires a PIN for payments, setting an allowance and ending it ask for the same + PIN (or biometrics) as Send before anything changes, and a cancelled check changes nothing. The + payee's answers never ask. Automatic payments under an accepted allowance never ask: the checked + grant is their authorisation. + + Precondition: the two-instance setup in README.md, with no allowance between them yet. On the + payer, enable a PIN you choose under Settings ▸ Security ▸ PIN Code and turn on "Also require for + payments"; no biometrics enrolled on the emulator, so the PIN pad shows directly. Never enter a + wrong PIN more than once. + + + On the payer instance, open Subscriptions, tap the Allowances tab, tap Add (testTag "AllowanceAdd"), pick the payee and keep $5 a payment and $50 a month + Tap Save Allowance (testTag "AllowanceSave") + Verify the PIN pad shows over the sheet, then press back + Verify the Set sheet (testTag "SetAllowance") is still showing with the same payee and limits, and the payee instance gets no review sheet + Tap Save Allowance again and enter the PIN + Verify the sheet dismisses and the payer's row reads "Waiting for an answer" + On the payee instance, verify the review sheet (testTag "AllowanceReview") opens on its own, tap Accept (testTag "AllowanceAccept") and verify no PIN is asked + On the payee instance, send the payer a one-time Payment Request for $2 and verify the payer pays it automatically with no PIN pad, per auto-pay-under-limit.xml + On the payer instance, tap the payee's row and swipe "Swipe To End Allowance" to the end + Verify the PIN pad shows, press back, and verify the detail sheet (testTag "AllowanceDetail") is back with the swipe control at its start and the allowance still Active + Swipe it to the end again, enter the PIN, and verify the row reads "Ended · $2.00 paid automatically" + + diff --git a/journeys/allowances/restart-never-pays-twice.xml b/journeys/allowances/restart-never-pays-twice.xml new file mode 100644 index 0000000000..21426038f4 --- /dev/null +++ b/journeys/allowances/restart-never-pays-twice.xml @@ -0,0 +1,21 @@ + + + Killing the payer while an automatic payment is in flight must never lead to a second payment + after relaunch. The app records the request as taken before it hands the payment to the node, so + on relaunch it either sees the node finish the first attempt or hands the request back to the + payer as an ordinary Payment Request; it never starts a new automatic attempt. The kill has to + land within about two seconds of the request arriving, which the app log marks with "Handed an + allowance payment to the node". + + + Launch the E2E Bitkit app on both instances with the $5 a payment, $50 a month allowance Active, per set-and-accept.xml + On the payer instance, start tailing the app log for "Handed an allowance payment to the node" so the kill can follow it at once + On the payee instance, send the payer a one-time Payment Request for $2 with the note "Devlog3" + As soon as the log line appears on the payer, run `adb shell am force-stop to.bitkit.dev` + Relaunch the payer with `adb shell am start -n to.bitkit.dev/to.bitkit.ui.MainActivity` and wait for the node to come back up + Verify no "Payment Executed" notification is posted for a second attempt after the relaunch + Open the drawer menu, tap Subscriptions and tap the Payments tab; verify "Devlog3" is listed exactly once + On the payee instance, verify at most one $2.00 payment arrives for "Devlog3" + If the kill landed before the node took the payment: on the payer, verify "Devlog3" comes back as an ordinary Payment Request in the bell (testTag "PaymentRequestsBell") rather than being paid automatically, and dismiss it + + diff --git a/journeys/allowances/set-and-accept.xml b/journeys/allowances/set-and-accept.xml new file mode 100644 index 0000000000..b8cf7daa6b --- /dev/null +++ b/journeys/allowances/set-and-accept.xml @@ -0,0 +1,26 @@ + + + The payer sets an allowance for a contact from the Allowances tab, and the payee sees the offer + open by itself and accepts it. Until the payee answers, the payer's row reads "Waiting for an + answer"; after it, both sides show the allowance as Active. The other allowance journeys start + from the Active state this one ends in. + + + Launch the E2E Bitkit app with Paykit UI enabled on both instances, each with the other saved as a linked contact, the payer holding a spendable balance above 50,000 sats with a usable Lightning channel + On the payer instance, open the drawer menu and tap Subscriptions + Tap the Allowances tab (testTag "Tab-allowances") + Verify the empty state (testTag "AllowancesEmpty") reads "Set up allowances" over the group illustration, with the footer button "Add Allowance" + Tap Add Allowance (testTag "AllowanceAdd") + Verify the Choose Contact sheet lists the payee and tap it (testTag "AllowanceContact-<displayName>") + Verify the Set Allowance sheet (testTag "SetAllowance") shows the payee's card, a PAYMENT LIMIT slider (testTag "AllowancePerPayment") and a MONTHLY ALLOWANCE slider (testTag "AllowanceMonthly") + Tap the $5 stop of the payment limit slider (testTag "AllowancePerPaymentStop-1") and the $50 stop of the monthly slider (testTag "AllowanceMonthlyStop-1") + Verify the summary (testTag "AllowanceSummary") reads "Requests up to $5 will be paid automatically, up to $50 a month, without asking you each time." + Tap Save Allowance (testTag "AllowanceSave") + Verify the sheet dismisses and the list shows one row for the payee (testTag "AllowanceRow-<entryId>") whose status line (testTag "AllowanceRowStatus") reads "Waiting for an answer", with "$ 50.00" over "Monthly limit" on the right + Switch to the payee instance and bring Bitkit to the foreground + Verify the Allowance review sheet (testTag "AllowanceReview") opens on its own within a few seconds, headed "<payer> offers an allowance", with the payer's contact card (testTag "AllowanceCounterparty"), PER PAYMENT "Up to $5.00" (testTag "AllowancePerPaymentValue") and EACH MONTH "Up to $50.00" (testTag "AllowanceMonthlyValue") + Tap Accept (testTag "AllowanceAccept") + Verify the sheet dismisses; open the drawer menu, tap Subscriptions, tap the Allowances tab and verify the payer's row reads "Active" followed by the per-payment limit + Switch back to the payer instance and verify its row now reads "Active · $5 a payment" + +