diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt index c127a13a68..bb7d85b1b3 100644 --- a/app/src/main/java/to/bitkit/App.kt +++ b/app/src/main/java/to/bitkit/App.kt @@ -16,6 +16,7 @@ import to.bitkit.env.Env import to.bitkit.services.BluetoothInit import to.bitkit.services.PubkyAuthHandlerRegistrar import to.bitkit.utils.Logger +import to.bitkit.utils.SubscriptionClockOffsetSync import javax.inject.Inject @HiltAndroidApp @@ -32,6 +33,9 @@ internal open class App : Application(), Configuration.Provider { @Inject lateinit var pubkyAuthHandlerRegistrar: PubkyAuthHandlerRegistrar + @Inject + lateinit var subscriptionClockOffsetSync: SubscriptionClockOffsetSync + override val workManagerConfiguration get() = Configuration.Builder() .setWorkerFactory(workerFactory) @@ -47,6 +51,7 @@ internal open class App : Application(), Configuration.Provider { // Initialize btleplug for Bluetooth support (required before any BLE usage) BluetoothInit.ensureInitialized() pubkyAuthHandlerRegistrar.start() + subscriptionClockOffsetSync.start() } private fun installUncaughtExceptionLogger() { diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index 891ee27210..6bb1d5436f 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -6,6 +6,7 @@ import androidx.datastore.dataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow @@ -33,6 +34,7 @@ private val Context.settingsDataStore: DataStore by dataStore( private val Context.localSettingsDataStore: DataStore by preferencesDataStore("local_settings") @Singleton +@Suppress("TooManyFunctions") class SettingsStore @Inject constructor( @ApplicationContext private val context: Context, ) { @@ -44,6 +46,7 @@ class SettingsStore @Inject constructor( val isPubkyProfileSetupPending: Flow = localStore.data.map { it[PUBKY_PROFILE_SETUP_PENDING_KEY] ?: false } + val subscriptionClockOffsetDays: Flow = localStore.data.map { it[SUBSCRIPTION_CLOCK_OFFSET_DAYS_KEY] ?: 0 } @Volatile var restoredMonitoredTypesFromBackup: Boolean = false @@ -110,6 +113,10 @@ class SettingsStore @Inject constructor( localStore.edit { it[PUBKY_PROFILE_SETUP_PENDING_KEY] = value } } + suspend fun setSubscriptionClockOffsetDays(days: Int) { + localStore.edit { it[SUBSCRIPTION_CLOCK_OFFSET_DAYS_KEY] = days } + } + suspend fun addLastUsedTag(newTag: String) { store.updateData { currentSettings -> val combinedTags = (listOf(newTag) + currentSettings.lastUsedTags).distinct() @@ -143,6 +150,7 @@ class SettingsStore @Inject constructor( private const val MAX_LAST_USED_TAGS = 10 private val PAYKIT_ENABLED_KEY = booleanPreferencesKey("paykit_enabled") private val PUBKY_PROFILE_SETUP_PENDING_KEY = booleanPreferencesKey("pubky_profile_setup_pending") + private val SUBSCRIPTION_CLOCK_OFFSET_DAYS_KEY = intPreferencesKey("subscription_clock_offset_days") } } diff --git a/app/src/main/java/to/bitkit/di/EnvModule.kt b/app/src/main/java/to/bitkit/di/EnvModule.kt index 24621dab02..08578a1a91 100644 --- a/app/src/main/java/to/bitkit/di/EnvModule.kt +++ b/app/src/main/java/to/bitkit/di/EnvModule.kt @@ -8,7 +8,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import org.lightningdevkit.ldknode.Network import to.bitkit.env.Env +import to.bitkit.utils.SubscriptionClockOffset import java.util.Locale +import javax.inject.Qualifier import kotlin.time.Clock import kotlin.time.ExperimentalTime @@ -23,6 +25,15 @@ object EnvModule { @Provides fun provideClock(): Clock = Clock.System + @Provides + @SubscriptionClock + fun provideSubscriptionClock(clock: Clock): Clock = SubscriptionClockOffset.subscriptionClock(clock) + @Provides fun provideLocale(@ApplicationContext context: Context): Locale = context.resources.configuration.locales[0] } + +/** The [Clock] for Paykit subscription scheduling; [SubscriptionClockOffset] can move it ahead in debug builds. */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class SubscriptionClock diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 8c7f905305..bf172a52d4 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -38,6 +38,7 @@ import to.bitkit.async.appScope import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher +import to.bitkit.di.SubscriptionClock import to.bitkit.env.Env import to.bitkit.ext.runSuspendCatching import to.bitkit.flags.PaykitFeatureFlags @@ -262,6 +263,7 @@ class PaykitPaymentRequestRepo @Inject constructor( private val paymentProofRepo: PaykitPaymentProofRepo, private val subscriptionNotificationScheduler: PaykitSubscriptionNotificationScheduler, private val clock: Clock, + @SubscriptionClock private val subscriptionClock: Clock, ) { companion object { private const val TAG = "PaykitPaymentRequestRepo" @@ -331,7 +333,7 @@ class PaykitPaymentRequestRepo @Inject constructor( _pendingRequests.value.filterNot { it.id in presentedRequestIds } fun subscriptionProposals(): List = - _subscriptions.value.filter { it.isPayer && it.isProposalVisible(clock.now()) } + _subscriptions.value.filter { it.isPayer && it.isProposalVisible(subscriptionClock.now()) } fun automaticSubscriptionProposals(): List = subscriptionProposals().filterNot { it.id in presentedSubscriptionProposalIds } @@ -366,7 +368,7 @@ class PaykitPaymentRequestRepo @Inject constructor( ): Boolean = withContext(ioDispatcher) { operationMutex.withLock { val current = _subscriptions.value.firstOrNull { it.id == subscription.id } - ?.takeIf { it.isPayer && it.isProposalVisible(clock.now()) } + ?.takeIf { it.isPayer && it.isProposalVisible(subscriptionClock.now()) } ?: return@withLock false if (current.id in presentedSubscriptionProposalIds) return@withLock true val identity = activeIdentity ?: return@withLock false @@ -613,14 +615,14 @@ class PaykitPaymentRequestRepo @Inject constructor( ): PaykitSubscriptionCreation { val generation = stateGeneration.get() val expectedIdentity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable - val validationDate = clock.now() + val validationDate = subscriptionClock.now() val name = draft.name.trim() val description = draft.description.trim() validateSubscriptionDraft(draft, name, validationDate) val endpoints = subscriptionProposalEndpoints(target, savedPublicKeys, expectedIdentity) val reservedIconUri = PaykitSubscriptionProposal.reservedIconUri.takeIf { draft.iconBytes != null } - val preflight = buildSubscriptionProposal(draft, name, description, reservedIconUri, endpoints, validationDate) + val preflight = buildSubscriptionProposal(draft, name, description, reservedIconUri, endpoints, clock.now()) PaykitSubscriptionProposal.validate(preflight) val iconUri = draft.iconBytes?.let { paykitSdkService.uploadProfileAvatar( @@ -629,9 +631,9 @@ class PaykitPaymentRequestRepo @Inject constructor( expectedIdentity = expectedIdentity, ) } - val proposalDate = clock.now() - validateProposalExpiration(draft.expiresAt, proposalDate) - val proposal = buildSubscriptionProposal(draft, name, description, iconUri, endpoints, proposalDate) + validateProposalExpiration(draft.expiresAt, subscriptionClock.now()) + // The published recurrence start and anchor stay on real time, so the offset never leaves this device. + val proposal = buildSubscriptionProposal(draft, name, description, iconUri, endpoints, clock.now()) PaykitSubscriptionProposal.validate(proposal) val record = paykitSdkService.proposePaymentRequest( counterparty = target.publicKey, @@ -693,7 +695,7 @@ class PaykitPaymentRequestRepo @Inject constructor( description: String, iconUri: String?, endpointIdentifiers: List, - proposalDate: Instant, + startsAt: Instant, ): PaykitPaymentRequestProposalTerms { val subscriptionMetadata = mutableMapOf( "version" to JsonPrimitive(1), @@ -701,7 +703,7 @@ class PaykitPaymentRequestRepo @Inject constructor( "benefits" to JsonArray(emptyList()), ) iconUri?.let { subscriptionMetadata["icon_uri"] = JsonPrimitive(it) } - val timestamp = Instant.fromEpochSeconds(proposalDate.epochSeconds).toString() + val timestamp = Instant.fromEpochSeconds(startsAt.epochSeconds).toString() return PaykitPaymentRequestProposalTerms( amountValue = draft.amountSats.toBitcoinAmount(), paymentReference = "bitkit-${UUID.randomUUID()}", @@ -841,7 +843,7 @@ class PaykitPaymentRequestRepo @Inject constructor( runSuspendCatching { operationMutex.withLock { val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable - val validationDate = clock.now() + val validationDate = subscriptionClock.now() val current = _subscriptions.value.firstOrNull { it.id == subscription.id } ?.takeIf { it == subscription && it.isPayer && it.isProposalActionable(validationDate) } ?: throw PaykitPaymentRequestError.RequestUnavailable @@ -851,10 +853,11 @@ class PaykitPaymentRequestRepo @Inject constructor( current.paymentRequestId, ) processPendingMessages() + // Stored on real time, so a period the offset made due is not dropped when it turns Off. val acceptanceDate = clock.now() subscriptionAcceptedAt = subscriptionAcceptedAt + (current.id to acceptanceDate) persistSubscriptionState(identity) - applySubscriptionRecordLocked(record, acceptanceDate) + applySubscriptionRecordLocked(record, subscriptionClock.now()) synchronizeAfterSubscriptionAction(identity) _pendingRequests.value .filter { it.belongsTo(current) } @@ -864,7 +867,7 @@ class PaykitPaymentRequestRepo @Inject constructor( } suspend fun cancel(subscription: PaykitSubscription): Result = updateSubscription(subscription) { - if (!it.canCancel(clock.now())) throw PaykitPaymentRequestError.RequestUnavailable + if (!it.canCancel(subscriptionClock.now())) throw PaykitPaymentRequestError.RequestUnavailable if (it.isPayer) { val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable val protectedRequestIds = paymentProofRepo.protectedRequestIdsForSubscriptionCancellation( @@ -910,6 +913,7 @@ class PaykitPaymentRequestRepo @Inject constructor( processPendingMessages() paykitSdkService.receivePrivateMessagesFromLinkedPeers().also(::logIntakeFailures) val now = clock.now() + val subscriptionNow = subscriptionClock.now() val records = paykitSdkService.paymentRequests() val blockedPeers = paykitSdkService.linkedPeers().filter { it.state == LinkedPeerState.BLOCKED } val availableRecords = records.filterNot { record -> @@ -926,7 +930,7 @@ class PaykitPaymentRequestRepo @Inject constructor( ?.let(paymentProofStore::inFlightRequestIds) .orEmpty() val allSubscriptions = records.mapNotNull(PaymentRequestRecord::toPaykitSubscription) - .map { it.withExpiredLifecycle(now) } + .map { it.withExpiredLifecycle(subscriptionNow) } val blockedSubscriptionIds = allSubscriptions.mapNotNull { subscription -> subscription.id.takeIf { blockedPeers.any { @@ -939,8 +943,8 @@ class PaykitPaymentRequestRepo @Inject constructor( val subscriptions = allSubscriptions.filterNot { subscription -> subscription.id in blockedSubscriptionIds && ( subscription.paidPeriods.isEmpty() || - subscription.isProposalVisible(now) || - subscription.isActive(now) + subscription.isProposalVisible(subscriptionNow) || + subscription.isActive(subscriptionNow) ) } val restoredAcceptances = allSubscriptions @@ -959,7 +963,7 @@ class PaykitPaymentRequestRepo @Inject constructor( val recurringRequestsBySubscription = actionableSubscriptions.filter { it.isPayer } .associateWith { subscription -> updatedSubscriptionAcceptedAt[subscription.id] - ?.let { subscription.requestsThrough(now, it) } + ?.let { subscription.requestsThrough(subscriptionNow, it) } .orEmpty() } val activeRecurringRequestIds = recurringRequestsBySubscription @@ -980,7 +984,7 @@ class PaykitPaymentRequestRepo @Inject constructor( } val recurringHistory = allSubscriptions.filter { it.isPayer }.flatMap { subscription -> updatedSubscriptionAcceptedAt[subscription.id] - ?.let { subscription.requestsThrough(now, it) } + ?.let { subscription.requestsThrough(subscriptionNow, it) } .orEmpty() }.mapNotNull { request -> when { @@ -1179,7 +1183,7 @@ class PaykitPaymentRequestRepo @Inject constructor( val record = operation(current) processPendingMessages() val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable - applySubscriptionRecordLocked(record, clock.now()) + applySubscriptionRecordLocked(record, subscriptionClock.now()) synchronizeAfterSubscriptionAction(identity) } }.onFailure { Logger.warn("Failed to update Paykit subscription", it, context = TAG) } @@ -1285,9 +1289,10 @@ class PaykitPaymentRequestRepo @Inject constructor( private suspend fun discardExpiredRequestsLocked() { val now = clock.now() + val subscriptionNow = subscriptionClock.now() _pendingRequests.update { requests -> requests.filterNot { it.isExpired(now) } } _paymentRequestHistory.update { requests -> requests.withExpiredLifecycle(now) } - _subscriptions.update { subscriptions -> subscriptions.map { it.withExpiredLifecycle(now) } } + _subscriptions.update { subscriptions -> subscriptions.map { it.withExpiredLifecycle(subscriptionNow) } } prunePresentedRequestIds(_pendingRequests.value) prunePresentedSubscriptionProposalIds(_subscriptions.value) scheduleExpirationLocked() @@ -1305,7 +1310,7 @@ class PaykitPaymentRequestRepo @Inject constructor( private suspend fun prunePresentedSubscriptionProposalIds(subscriptions: List) { val proposalIds = subscriptions - .filter { it.isPayer && it.isProposalVisible(clock.now()) } + .filter { it.isPayer && it.isProposalVisible(subscriptionClock.now()) } .mapTo(mutableSetOf()) { it.id } val prunedIds = presentedSubscriptionProposalIds.intersect(proposalIds) if (prunedIds == presentedSubscriptionProposalIds) return @@ -1341,19 +1346,23 @@ class PaykitPaymentRequestRepo @Inject constructor( expirationJob?.cancel() expirationJob = null - val requestExpirations = (_pendingRequests.value + _paymentRequestHistory.value) + val now = clock.now() + val subscriptionNow = subscriptionClock.now() + val requestDelays = (_pendingRequests.value + _paymentRequestHistory.value) .filter { it.lifecycleState == PaymentRequestLifecycleState.PROPOSED } .mapNotNull { it.expiresAt } - val subscriptionExpirations = _subscriptions.value + .map { it - now } + // Subscription dates run on the subscription clock, which the offset can move ahead of real time. + val subscriptionDelays = _subscriptions.value .filter { it.lifecycleState == PaymentRequestLifecycleState.PROPOSED || it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING } .flatMap { listOfNotNull(it.proposalExpiresAt, it.recurrence.endsAt) } - .filter { it > clock.now() } - val nextExpiration = (requestExpirations + subscriptionExpirations).minOrNull() + .filter { it > subscriptionNow } + .map { it - subscriptionNow } + val delayDuration = (requestDelays + subscriptionDelays).minOrNull()?.coerceAtLeast(Duration.ZERO) ?: return - val delayDuration = (nextExpiration - clock.now()).coerceAtLeast(Duration.ZERO) expirationJob = repoScope.launch { delay(delayDuration) operationMutex.withLock { diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt index 6e123cc896..34dcedb4ff 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt @@ -262,8 +262,9 @@ data class PaykitSubscription( } } - fun paymentDueOnAcceptance(now: Instant): PaykitPaymentRequest? = - if (hasPaymentDeadline) null else requestsThrough(now, now).firstOrNull() + /** [now] is the subscription clock; [acceptedAt] is when accepting will store the acceptance, in real time. */ + fun paymentDueOnAcceptance(now: Instant, acceptedAt: Instant = now): PaykitPaymentRequest? = + if (hasPaymentDeadline) null else requestsThrough(now, acceptedAt).firstOrNull() fun receivedPaymentRequests(): List { if (!isCreatedByUser) return emptyList() diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt index b268e4e334..3bb9e0d758 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt @@ -17,6 +17,7 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext import to.bitkit.App import to.bitkit.R +import to.bitkit.di.SubscriptionClock import to.bitkit.ext.runSuspendCatching import to.bitkit.ui.EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT import to.bitkit.ui.EXTRA_PAYKIT_COUNTERPARTY @@ -25,29 +26,33 @@ import to.bitkit.ui.EXTRA_PAYKIT_PAYER_IDENTITY import to.bitkit.ui.EXTRA_PAYKIT_PAYMENT_REQUEST_ID import to.bitkit.ui.EXTRA_PAYKIT_SUBSCRIPTION_PAYMENT_DUE import to.bitkit.ui.pushNotification +import to.bitkit.utils.SubscriptionClockOffset import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock +import kotlin.time.Duration import kotlin.time.ExperimentalTime import kotlin.time.Instant @Singleton class PaykitSubscriptionNotificationScheduler @Inject constructor( @ApplicationContext private val context: Context, - private val clock: Clock, + @SubscriptionClock private val clock: Clock, private val workClient: PaykitSubscriptionWorkClient, ) { private companion object { const val MAX_NOTIFICATIONS = 32 const val PREFERENCES_NAME = "paykit-subscription-notifications" const val SCHEDULED_WORK_NAMES_KEY = "scheduled-work-names" + const val SCHEDULED_OFFSET_DAYS_KEY = "scheduled-clock-offset-days" const val WORK_PREFIX = "paykit-subscription-" const val WORK_TAG = "paykit-subscriptions" } private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) private var scheduledWorkNames = preferences.getStringSet(SCHEDULED_WORK_NAMES_KEY, emptySet()).orEmpty() + private var scheduledOffsetDays = preferences.getInt(SCHEDULED_OFFSET_DAYS_KEY, 0) private var notificationsWereEnabled: Boolean? = null @Synchronized @@ -66,45 +71,48 @@ class PaykitSubscriptionNotificationScheduler @Inject constructor( } val now = clock.now() + // A changed subscription clock offset moves every due time, so work queued for the old one is replaced and + // periods the jump made due are notified too. + val offsetChanged = SubscriptionClockOffset.offsetDays != scheduledOffsetDays + if (offsetChanged) { + workClient.cancelAllWorkByTag(WORK_TAG) + updateScheduledWorkNames(emptySet()) + updateScheduledOffsetDays(SubscriptionClockOffset.offsetDays) + } + val pendingWorkNames = pendingRequestIds.mapNotNullTo(mutableSetOf()) { requestId -> + requestId.billingPeriodStartsAt?.let { + "$WORK_PREFIX$payerIdentity|${requestId.counterparty}|${requestId.counterpartyReceiverPath}|" + + "${requestId.paymentRequestId}|$it" + } + } + val scheduledWork = subscriptions .filter { it.isPayer && - it.isActive(now) && !it.hasPaymentDeadline && it.recurrence.unit.isSupported && acceptedAt(it) != null } .flatMap { subscription -> - subscription.recurrence.upcomingPeriodsAfter(now, MAX_NOTIFICATIONS) - .map { subscription to it } + // A jump can pass the end of a finite subscription, whose last unpaid period still needs its alert. + // Only the latest unpaid period alerts, so a long jump cannot fill every slot with catch-up alerts. + val duePeriod = acceptedAt(subscription) + ?.takeIf { offsetChanged } + ?.let { subscription.recurrence.periodsThrough(now, it) } + ?.lastOrNull { workName(payerIdentity, subscription, it) in pendingWorkNames } + val upcomingPeriods = if (subscription.isActive(now)) { + subscription.recurrence.upcomingPeriodsAfter(now, MAX_NOTIFICATIONS) + } else { + emptyList() + } + (listOfNotNull(duePeriod) + upcomingPeriods).map { subscription to it } } .sortedBy { it.second.startsAt } .take(MAX_NOTIFICATIONS) .associate { (subscription, period) -> - val workName = "$WORK_PREFIX$payerIdentity|${subscription.counterparty}|" + - "${subscription.counterpartyReceiverPath}|${subscription.paymentRequestId}|${period.startsAt}" - val delay = (period.startsAt - now).inWholeMilliseconds.coerceAtLeast(0) - val work = OneTimeWorkRequestBuilder() - .setInitialDelay(delay, TimeUnit.MILLISECONDS) - .setInputData( - workDataOf( - EXTRA_PAYKIT_PAYMENT_REQUEST_ID to subscription.paymentRequestId, - EXTRA_PAYKIT_PAYER_IDENTITY to payerIdentity, - EXTRA_PAYKIT_COUNTERPARTY to subscription.counterparty, - EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH to subscription.counterpartyReceiverPath, - EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT to period.startsAt.toString(), - ) - ) - .addTag(WORK_TAG) - .build() - workName to work + workName(payerIdentity, subscription, period) to + notificationWork(payerIdentity, subscription, period, delay = period.startsAt - now) } - val pendingWorkNames = pendingRequestIds.mapNotNullTo(mutableSetOf()) { requestId -> - requestId.billingPeriodStartsAt?.let { - "$WORK_PREFIX$payerIdentity|${requestId.counterparty}|${requestId.counterpartyReceiverPath}|" + - "${requestId.paymentRequestId}|$it" - } - } val desiredWorkNames = scheduledWork.keys + scheduledWorkNames.intersect(pendingWorkNames) (scheduledWorkNames - desiredWorkNames).forEach(workClient::cancelUniqueWork) scheduledWork @@ -123,6 +131,34 @@ class PaykitSubscriptionNotificationScheduler @Inject constructor( notificationsWereEnabled = false } + private fun workName(payerIdentity: String, subscription: PaykitSubscription, period: PaykitBillingPeriod) = + "$WORK_PREFIX$payerIdentity|${subscription.counterparty}|" + + "${subscription.counterpartyReceiverPath}|${subscription.paymentRequestId}|${period.startsAt}" + + private fun notificationWork( + payerIdentity: String, + subscription: PaykitSubscription, + period: PaykitBillingPeriod, + delay: Duration, + ): OneTimeWorkRequest = OneTimeWorkRequestBuilder() + .setInitialDelay(delay.inWholeMilliseconds.coerceAtLeast(0), TimeUnit.MILLISECONDS) + .setInputData( + workDataOf( + EXTRA_PAYKIT_PAYMENT_REQUEST_ID to subscription.paymentRequestId, + EXTRA_PAYKIT_PAYER_IDENTITY to payerIdentity, + EXTRA_PAYKIT_COUNTERPARTY to subscription.counterparty, + EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH to subscription.counterpartyReceiverPath, + EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT to period.startsAt.toString(), + ) + ) + .addTag(WORK_TAG) + .build() + + private fun updateScheduledOffsetDays(days: Int) { + scheduledOffsetDays = days + preferences.edit().putInt(SCHEDULED_OFFSET_DAYS_KEY, days).apply() + } + private fun updateScheduledWorkNames(workNames: Set) { scheduledWorkNames = workNames preferences.edit().putStringSet(SCHEDULED_WORK_NAMES_KEY, workNames).apply() @@ -154,7 +190,7 @@ class PaykitSubscriptionWorkClient @Inject constructor( class PaykitSubscriptionNotificationWorker @AssistedInject constructor( @Assisted appContext: Context, @Assisted workerParams: WorkerParameters, - private val clock: Clock, + @SubscriptionClock private val clock: Clock, ) : CoroutineWorker(appContext, workerParams) { override suspend fun doWork(): Result { val startsAt = inputData.getString(EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT) diff --git a/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt index b0f7eb9b23..657fec0fc4 100644 --- a/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt @@ -1,10 +1,13 @@ package to.bitkit.ui.screens.settings +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -14,6 +17,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -29,6 +33,7 @@ import to.bitkit.ui.activityListViewModel import to.bitkit.ui.appViewModel import to.bitkit.ui.components.settings.SectionHeader import to.bitkit.ui.components.settings.SettingsButtonRow +import to.bitkit.ui.components.settings.SettingsButtonValue import to.bitkit.ui.components.settings.SettingsSwitchRow import to.bitkit.ui.components.settings.SettingsTextButtonRow import to.bitkit.ui.navigateTo @@ -37,6 +42,7 @@ import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.settingsViewModel import to.bitkit.ui.shared.util.shareZipFile +import to.bitkit.utils.SubscriptionClockOffset import to.bitkit.viewmodels.DevSettingsViewModel @Composable @@ -51,6 +57,7 @@ fun DevSettingsScreen( val isPaykitEnabled by settings.isPaykitEnabled.collectAsStateWithLifecycle() val disableAllToasts by settings.disableAllToasts.collectAsStateWithLifecycle() val isSavingsSwapEnabled by settings.isSavingsSwapEnabled.collectAsStateWithLifecycle() + val subscriptionClockOffsetDays by settings.subscriptionClockOffsetDays.collectAsStateWithLifecycle() var showPaykitWarning by remember { mutableStateOf(false) } ScreenColumn { @@ -108,6 +115,12 @@ fun DevSettingsScreen( }, switchTestTag = "PaykitUiToggle", ) + if (SubscriptionClockOffset.isAvailable) { + SubscriptionClockOffsetRow( + offsetDays = subscriptionClockOffsetDays, + onSelect = settings::setSubscriptionClockOffsetDays, + ) + } } SectionHeader("HARDWARE WALLET") @@ -278,3 +291,36 @@ fun DevSettingsScreen( ) } } + +@Composable +private fun SubscriptionClockOffsetRow( + offsetDays: Int, + onSelect: (Int) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Box { + SettingsButtonRow( + title = "Subscription clock offset (days)", + value = SettingsButtonValue.StringValue(subscriptionClockOffsetLabel(offsetDays)), + onClick = { expanded = true }, + modifier = Modifier.testTag("SubscriptionClockOffset") + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + SubscriptionClockOffset.OFFSET_DAYS_PRESETS.forEach { days -> + DropdownMenuItem( + text = { Text(subscriptionClockOffsetLabel(days)) }, + onClick = { + expanded = false + onSelect(days) + }, + modifier = Modifier.testTag("SubscriptionClockOffset-$days") + ) + } + } + } +} + +private fun subscriptionClockOffsetLabel(days: Int) = if (days == 0) "Off" else "$days" diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt index 25487fac6e..c7b04259c7 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt @@ -88,10 +88,10 @@ 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.utils.SubscriptionClockOffset import to.bitkit.utils.SubscriptionIcon import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.AppViewModel -import kotlin.time.Clock import kotlin.time.ExperimentalTime @Composable @@ -155,7 +155,7 @@ fun CreateSubscriptionSheet( initialDraft = PaykitPaymentRequestDraft( amountSats = amountSats, note = name, - expiresAt = Clock.System.now() + expiration.duration, + expiresAt = SubscriptionClockOffset.subscriptionNow() + expiration.duration, ), contact = null, onBack = { step = SubscriptionCreationStep.Details }, @@ -184,7 +184,7 @@ fun CreateSubscriptionSheet( name = name, description = description, frequency = frequency, - expiresAt = Clock.System.now() + expiration.duration, + expiresAt = SubscriptionClockOffset.subscriptionNow() + expiration.duration, iconBytes = iconBytes, ), target = target, 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 12a688b986..26ee057e24 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 @@ -102,6 +102,7 @@ import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.removeAccentTags import to.bitkit.ui.utils.withAccent +import to.bitkit.utils.SubscriptionClockOffset import to.bitkit.viewmodels.AppViewModel import java.math.BigDecimal import java.math.MathContext @@ -650,7 +651,7 @@ fun SubscriptionSheet(appViewModel: AppViewModel, initialRoute: SubscriptionRout VerticalSpacer(16.dp) } } else { - val payOnAcceptance = subscription.paymentDueOnAcceptance(now) != null + val payOnAcceptance = subscription.paymentDueOnAcceptance(now, Clock.System.now()) != null when (route) { is SubscriptionRoute.Review -> SubscriptionReview( subscription = subscription, @@ -748,7 +749,7 @@ private fun SubscriptionReview( MoneyDisplay(sats = subscription.displaySats, showSymbol = true) VerticalSpacer(24.dp) SubscriptionProviderCard(subscription, contact, onClick = onDetails) - subscription.paymentDueOnAcceptance(now)?.billingPeriod?.let { period -> + subscription.paymentDueOnAcceptance(now, Clock.System.now())?.billingPeriod?.let { period -> VerticalSpacer(16.dp) BodyS( text = stringResource( @@ -945,7 +946,7 @@ private fun SubscriptionCancel( SubscriptionProviderCard( subscription = subscription, contact = contact, - subtitle = subscription.rowSubtitle(Clock.System.now()), + subtitle = subscription.rowSubtitle(SubscriptionClockOffset.subscriptionNow()), onClick = onDetails, ) FillHeight() @@ -1117,11 +1118,12 @@ private fun PaykitSubscription.renewalText(now: Instant): String { @Composable private fun rememberSubscriptionNow(subscriptions: ImmutableList): Instant { - var now by remember(subscriptions) { mutableStateOf(Clock.System.now()) } + var now by remember(subscriptions) { mutableStateOf(SubscriptionClockOffset.subscriptionNow()) } LaunchedEffect(subscriptions, now) { - val nextTransition = nextSubscriptionTransition(subscriptions, now) ?: return@LaunchedEffect + val nextTransition = nextSubscriptionTransition(subscriptions, now, Clock.System.now()) + ?: return@LaunchedEffect delay(nextTransition - now) - now = Clock.System.now() + now = SubscriptionClockOffset.subscriptionNow() } return now } @@ -1129,13 +1131,14 @@ private fun rememberSubscriptionNow(subscriptions: ImmutableList, now: Instant, + acceptedAt: Instant = now, ): Instant? { val activeSubscriptions = subscriptions.filter { it.isActive(now) } val dates = subscriptions.flatMap { listOf(it.recurrence.startsAt, it.proposalExpiresAt, it.recurrence.endsAt) }.filterNotNull().toMutableList() dates += activeSubscriptions.mapNotNull { it.recurrence.nextPeriodAfter(now)?.startsAt } - dates += subscriptions.mapNotNull { it.paymentDueOnAcceptance(now)?.billingPeriod?.endsAt } + dates += subscriptions.mapNotNull { it.paymentDueOnAcceptance(now, acceptedAt)?.billingPeriod?.endsAt } return dates.filter { it > now }.minOrNull() } diff --git a/app/src/main/java/to/bitkit/utils/SubscriptionClockOffset.kt b/app/src/main/java/to/bitkit/utils/SubscriptionClockOffset.kt new file mode 100644 index 0000000000..904489ea73 --- /dev/null +++ b/app/src/main/java/to/bitkit/utils/SubscriptionClockOffset.kt @@ -0,0 +1,87 @@ +package to.bitkit.utils + +import dagger.Lazy +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import to.bitkit.BuildConfig +import to.bitkit.async.appScope +import to.bitkit.data.SettingsStore +import to.bitkit.di.IoDispatcher +import to.bitkit.repositories.PaykitPaymentRequestRepo +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant + +/** + * Dev-only offset that moves Paykit subscription scheduling forward, so a renewal can be tested on a debug build + * without waiting a whole billing period. It covers subscription proposals, acceptance, due periods, renewal dates and + * due notifications. One-time payment requests, invoices and payments keep real time. + */ +object SubscriptionClockOffset { + /** Offsets the Dev Settings accept, in whole days. */ + val OFFSET_DAYS_RANGE = 0..400 + + /** Offsets offered in Dev Settings, in whole days. */ + val OFFSET_DAYS_PRESETS = listOf(0, 1, 7, 30, 31, 62, 365) + + val isAvailable: Boolean get() = BuildConfig.DEBUG + + @Volatile + var offsetDays: Int = 0 + private set + + fun setOffsetDays(days: Int) = run { offsetDays = clampedOffsetDays(days) } + + fun clampedOffsetDays(days: Int): Int = days.coerceIn(OFFSET_DAYS_RANGE) + + fun subscriptionDate( + date: Instant, + offsetDays: Int = this.offsetDays, + isAvailable: Boolean = this.isAvailable, + ): Instant { + if (!isAvailable) return date + val days = clampedOffsetDays(offsetDays) + if (days == 0) return date + return date + days.days + } + + fun subscriptionNow(clock: Clock = Clock.System): Instant = subscriptionDate(clock.now()) + + /** [base] moved by the current offset, for code that schedules subscriptions through an injected [Clock]. */ + fun subscriptionClock(base: Clock): Clock = object : Clock { + override fun now(): Instant = subscriptionDate(base.now()) + } +} + +/** + * Keeps [SubscriptionClockOffset.offsetDays] in step with the offset stored by Dev Settings, and refreshes the Paykit + * subscriptions when it changes so due periods, notifications and expirations follow the new offset right away. + */ +@Singleton +class SubscriptionClockOffsetSync @Inject constructor( + private val settingsStore: SettingsStore, + private val paykitPaymentRequestRepo: Lazy, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) { + companion object { + private const val TAG = "SubscriptionClockOffsetSync" + } + + private val scope = appScope(ioDispatcher, TAG) + + fun start() { + if (!SubscriptionClockOffset.isAvailable) return + scope.launch { + var isInitialOffset = true + settingsStore.subscriptionClockOffsetDays.distinctUntilChanged().collect { + SubscriptionClockOffset.setOffsetDays(it) + if (it != 0) Logger.info("Set the subscription clock offset to '$it' days", context = TAG) + if (!isInitialOffset) paykitPaymentRequestRepo.get().refresh() + isInitialOffset = false + } + } + } +} diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index f2cb0a95d4..d50c4da1b4 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt @@ -35,6 +35,7 @@ import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.WidgetsRepo import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger +import to.bitkit.utils.SubscriptionClockOffset import javax.inject.Inject @Suppress("LongParameterList", "TooManyFunctions") @@ -220,6 +221,16 @@ class SettingsViewModel @Inject constructor( val isPaykitStateLoaded = settingsStore.isPaykitEnabled.map { true } .asStateFlow(initialValue = false) + val subscriptionClockOffsetDays = settingsStore.subscriptionClockOffsetDays + .map { SubscriptionClockOffset.clampedOffsetDays(it) } + .asStateFlow(initialValue = 0) + + fun setSubscriptionClockOffsetDays(days: Int) { + viewModelScope.launch { + settingsStore.setSubscriptionClockOffsetDays(SubscriptionClockOffset.clampedOffsetDays(days)) + } + } + val contactPaymentsEnabled = contactPaymentSettingsRepo.isEnabled .asStateFlow(initialValue = false) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index 7efb36af63..9e2a460ae6 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -48,6 +48,8 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime @@ -81,6 +83,10 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat (testDispatcher.scheduler.currentTime - schedulerOriginMillis).milliseconds, ) } + private var subscriptionOffset = Duration.ZERO + private val subscriptionClock = object : Clock { + override fun now(): Instant = clock.now() + subscriptionOffset + } private lateinit var sut: PaykitPaymentRequestRepo @Before @@ -110,6 +116,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat paymentProofRepo, notificationScheduler, clock, + subscriptionClock, ) sut.activate(LOCAL_IDENTITY) } @@ -219,28 +226,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat @Test fun `creator proposal sends recurring terms and stays queued until delivery`() = test { - val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) - whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), - ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), - ) - whenever(paykitSdkService.proposePaymentRequest(any(), any(), any(), eq(LOCAL_IDENTITY))) - .thenAnswer { invocation -> - val proposal = invocation.getArgument(2) - paymentRequestRecord( - role = PaymentRequestLocalRole.PAYEE, - expiresAt = proposal.proposalExpiresAt, - recurrence = requireNotNull(proposal.recurrence).let { - PaymentRequestRecurrence(it.every, it.unit, it.startsAt, it.anchor, it.endsAt) - }, - metadata = mock { - on { exportText() } doReturn proposal.metadataJson - }, - ) - } + val target = stubSubscriptionProposal() val expiresAt = clock.now().plus(60.seconds) val creation = sut.proposeSubscription( @@ -280,6 +266,31 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat assertEquals(listOf(creation.subscription), sut.subscriptions.value) } + @Test + fun `creator proposal publishes real time while the subscription clock offset is on`() = test { + val target = stubSubscriptionProposal() + subscriptionOffset = 31.days + + sut.proposeSubscription( + draft = PaykitSubscriptionDraft( + amountSats = 100_000uL, + name = "Monthly support", + description = "Thank you", + frequency = PaykitRecurrenceUnit.Month, + expiresAt = subscriptionClock.now().plus(60.seconds), + ), + target = target, + savedPublicKeys = listOf(COUNTERPARTY), + ).getOrThrow() + + val captured = argumentCaptor() + verifyBlocking(paykitSdkService) { + proposePaymentRequest(any(), any(), captured.capture(), any()) + } + assertEquals(clock.now().toString(), captured.firstValue.recurrence?.startsAt) + assertEquals(clock.now().toString(), captured.firstValue.recurrence?.anchor) + } + @Test fun `oversized creator proposal is rejected before icon upload or enqueue`() = test { val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) @@ -449,6 +460,102 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat } } + @Test + fun `subscription clock offset makes the next billing period due`() = test { + val proposal = paymentRequestRecord() + val active = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(proposal), listOf(active)) + whenever( + paykitSdkService.acceptPaymentRequest( + COUNTERPARTY, + PaykitReceiverPaths.SERVER, + PAYMENT_REQUEST_ID, + ) + ).thenReturn(active) + sut.refresh().getOrThrow() + sut.accept(sut.subscriptions.value.single()).getOrThrow() + assertEquals( + listOf(Instant.parse("2027-01-01T08:00:00Z")), + sut.pendingRequests.value.mapNotNull { it.billingPeriod?.startsAt }, + ) + + subscriptionOffset = 31.days + sut.refresh().getOrThrow() + + assertEquals( + listOf(Instant.parse("2027-01-01T08:00:00Z"), Instant.parse("2027-02-01T08:00:00Z")), + sut.pendingRequests.value.mapNotNull { it.billingPeriod?.startsAt }.sorted(), + ) + } + + @Test + fun `accepting with the subscription clock offset on keeps the first period due`() = test { + val proposal = paymentRequestRecord() + val active = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(proposal), listOf(active)) + whenever( + paykitSdkService.acceptPaymentRequest( + COUNTERPARTY, + PaykitReceiverPaths.SERVER, + PAYMENT_REQUEST_ID, + ) + ).thenReturn(active) + sut.refresh().getOrThrow() + val subscription = sut.subscriptions.value.single() + + subscriptionOffset = 31.days + sut.accept(subscription).getOrThrow() + + assertEquals(clock.now(), sut.acceptedAt(sut.subscriptions.value.single())) + assertEquals( + listOf(Instant.parse("2027-01-01T08:00:00Z"), Instant.parse("2027-02-01T08:00:00Z")), + sut.pendingRequests.value.mapNotNull { it.billingPeriod?.startsAt }.sorted(), + ) + + subscriptionOffset = Duration.ZERO + sut.refresh().getOrThrow() + + assertEquals( + listOf(Instant.parse("2027-01-01T08:00:00Z")), + sut.pendingRequests.value.mapNotNull { it.billingPeriod?.startsAt }, + ) + } + + @Test + fun `subscription clock offset lists the paid next period in the payment history`() = test { + val proposal = paymentRequestRecord() + val active = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(proposal), listOf(active)) + whenever( + paykitSdkService.acceptPaymentRequest( + COUNTERPARTY, + PaykitReceiverPaths.SERVER, + PAYMENT_REQUEST_ID, + ) + ).thenReturn(active) + sut.refresh().getOrThrow() + sut.accept(sut.subscriptions.value.single()).getOrThrow() + val completedProofKinds = listOf("2027-01-01T08:00:00Z", "2027-02-01T08:00:00Z").associate { + PaykitPaymentRequestId( + paymentRequestId = PAYMENT_REQUEST_ID, + counterparty = COUNTERPARTY, + counterpartyReceiverPath = PaykitReceiverPaths.SERVER, + billingPeriodStartsAt = it, + ) to PaykitPaymentProofKind.Onchain + } + whenever(paymentProofStore.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY)) + .thenReturn(completedProofKinds) + + subscriptionOffset = 31.days + sut.refresh().getOrThrow() + + assertTrue(sut.pendingRequests.value.isEmpty()) + assertEquals( + listOf(Instant.parse("2027-01-01T08:00:00Z"), Instant.parse("2027-02-01T08:00:00Z")), + sut.paymentRequestHistory.value.mapNotNull { it.billingPeriod?.startsAt }.sorted(), + ) + } + @Test fun `accepting subscription rejects terms changed after review`() = test { val reviewedRecord = paymentRequestRecord() @@ -923,6 +1030,32 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ) } + private suspend fun stubSubscriptionProposal(): PaykitPaymentRequestTarget { + val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever(paykitSdkService.linkedPeers()).thenReturn( + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + ) + whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( + listOf(PaykitReceiverPaths.SERVER), + ) + whenever(paykitSdkService.proposePaymentRequest(any(), any(), any(), eq(LOCAL_IDENTITY))) + .thenAnswer { invocation -> + val proposal = invocation.getArgument(2) + paymentRequestRecord( + role = PaymentRequestLocalRole.PAYEE, + expiresAt = proposal.proposalExpiresAt, + recurrence = requireNotNull(proposal.recurrence).let { + PaymentRequestRecurrence(it.every, it.unit, it.startsAt, it.anchor, it.endsAt) + }, + metadata = mock { + on { exportText() } doReturn proposal.metadataJson + }, + ) + } + return target + } + @Suppress("LongParameterList") private fun paymentRequestRecord( id: String = PAYMENT_REQUEST_ID, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index c198abd9da..657b72ac11 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -113,6 +113,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentProofRepo, subscriptionNotificationScheduler, clock, + clock, ) sut.activate(LOCAL_IDENTITY) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt index b1641949a0..ad1e9dea63 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt @@ -17,6 +17,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.atLeast import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -30,9 +31,12 @@ import to.bitkit.ui.EXTRA_PAYKIT_COUNTERPARTY import to.bitkit.ui.EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH import to.bitkit.ui.EXTRA_PAYKIT_PAYER_IDENTITY import to.bitkit.ui.EXTRA_PAYKIT_PAYMENT_REQUEST_ID +import to.bitkit.utils.SubscriptionClockOffset import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days import kotlin.time.ExperimentalTime import kotlin.time.Instant @@ -53,8 +57,9 @@ class PaykitSubscriptionNotificationSchedulerTest { private val context = ApplicationProvider.getApplicationContext() private val workClient = mock() + private var clockOffset = Duration.ZERO private val clock = object : Clock { - override fun now(): Instant = NOW + override fun now(): Instant = NOW + clockOffset } private lateinit var sut: PaykitSubscriptionNotificationScheduler @@ -66,6 +71,7 @@ class PaykitSubscriptionNotificationSchedulerTest { @After fun tearDown() { + SubscriptionClockOffset.setOffsetDays(0) clearPreferences() } @@ -106,6 +112,160 @@ class PaykitSubscriptionNotificationSchedulerTest { assertEquals(ListenableWorker.Result.retry(), worker.doWork()) } + @Test + fun `worker notifies once the subscription clock reaches the billing period`() = runTest { + clockOffset = 7.days + val params = mock() + whenever(params.inputData).thenReturn( + workDataOf(EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT to NEXT_PERIOD_START.toString()), + ) + val worker = PaykitSubscriptionNotificationWorker(context, params, clock) + + assertEquals(ListenableWorker.Result.success(), worker.doWork()) + } + + @Test + fun `offset change reschedules queued work against the new subscription clock`() { + sut.synchronize( + subscriptions = listOf(subscription()), + acceptedAt = { NOW }, + pendingRequestIds = emptySet(), + payerIdentity = PAYER_IDENTITY, + notificationsEnabled = true, + ) + clearInvocations(workClient) + + SubscriptionClockOffset.setOffsetDays(3) + clockOffset = 3.days + sut.synchronize( + subscriptions = listOf(subscription()), + acceptedAt = { NOW }, + pendingRequestIds = emptySet(), + payerIdentity = PAYER_IDENTITY, + notificationsEnabled = true, + ) + + verify(workClient).cancelAllWorkByTag(WORK_TAG) + val requestCaptor = argumentCaptor() + verify(workClient).enqueueUniqueWork(eq(WORK_NAME), eq(ExistingWorkPolicy.KEEP), requestCaptor.capture()) + assertEquals( + (NEXT_PERIOD_START - (NOW + 3.days)).inWholeMilliseconds, + requestCaptor.firstValue.workSpec.initialDelay, + ) + } + + @Test + fun `offset change notifies the period it made due`() { + sut.synchronize( + subscriptions = listOf(subscription()), + acceptedAt = { NOW }, + pendingRequestIds = emptySet(), + payerIdentity = PAYER_IDENTITY, + notificationsEnabled = true, + ) + clearInvocations(workClient) + + SubscriptionClockOffset.setOffsetDays(7) + clockOffset = 7.days + sut.synchronize( + subscriptions = listOf(subscription()), + acceptedAt = { NOW }, + pendingRequestIds = setOf( + PaykitPaymentRequestId( + paymentRequestId = PAYMENT_REQUEST_ID, + counterparty = COUNTERPARTY, + counterpartyReceiverPath = RECEIVER_PATH, + billingPeriodStartsAt = NEXT_PERIOD_START.toString(), + ) + ), + payerIdentity = PAYER_IDENTITY, + notificationsEnabled = true, + ) + + val requestCaptor = argumentCaptor() + verify(workClient).enqueueUniqueWork(eq(WORK_NAME), eq(ExistingWorkPolicy.KEEP), requestCaptor.capture()) + assertEquals(0L, requestCaptor.firstValue.workSpec.initialDelay) + } + + @Test + fun `offset change notifies only the latest due period per subscription`() { + sut.synchronize( + subscriptions = listOf(subscription()), + acceptedAt = { NOW }, + pendingRequestIds = emptySet(), + payerIdentity = PAYER_IDENTITY, + notificationsEnabled = true, + ) + clearInvocations(workClient) + + SubscriptionClockOffset.setOffsetDays(100) + clockOffset = 100.days + val now = NOW + 100.days + val duePeriods = subscription().recurrence.periodsThrough(now, NOW) + sut.synchronize( + subscriptions = listOf(subscription()), + acceptedAt = { NOW }, + pendingRequestIds = duePeriods.mapTo(mutableSetOf()) { + PaykitPaymentRequestId( + paymentRequestId = PAYMENT_REQUEST_ID, + counterparty = COUNTERPARTY, + counterpartyReceiverPath = RECEIVER_PATH, + billingPeriodStartsAt = it.startsAt.toString(), + ) + }, + payerIdentity = PAYER_IDENTITY, + notificationsEnabled = true, + ) + + assertTrue(duePeriods.size > 2) + val requestCaptor = argumentCaptor() + verify(workClient, atLeast(1)) + .enqueueUniqueWork(any(), eq(ExistingWorkPolicy.KEEP), requestCaptor.capture()) + val immediate = requestCaptor.allValues.filter { it.workSpec.initialDelay == 0L } + assertEquals(1, immediate.size) + assertEquals( + duePeriods.last().startsAt.toString(), + immediate.single().workSpec.input.getString(EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT), + ) + assertTrue(requestCaptor.allValues.any { it.workSpec.initialDelay > 0L }) + } + + @Test + fun `offset change past the subscription end still notifies the final due period`() { + val ending = subscription().let { + it.copy(recurrence = it.recurrence.copy(endsAt = Instant.parse("2027-01-12T08:00:00Z"))) + } + sut.synchronize( + subscriptions = listOf(ending), + acceptedAt = { NOW }, + pendingRequestIds = emptySet(), + payerIdentity = PAYER_IDENTITY, + notificationsEnabled = true, + ) + clearInvocations(workClient) + + SubscriptionClockOffset.setOffsetDays(14) + clockOffset = 14.days + sut.synchronize( + subscriptions = listOf(ending), + acceptedAt = { NOW }, + pendingRequestIds = setOf( + PaykitPaymentRequestId( + paymentRequestId = PAYMENT_REQUEST_ID, + counterparty = COUNTERPARTY, + counterpartyReceiverPath = RECEIVER_PATH, + billingPeriodStartsAt = NEXT_PERIOD_START.toString(), + ) + ), + payerIdentity = PAYER_IDENTITY, + notificationsEnabled = true, + ) + + val requestCaptor = argumentCaptor() + verify(workClient).enqueueUniqueWork(eq(WORK_NAME), eq(ExistingWorkPolicy.KEEP), requestCaptor.capture()) + assertEquals(0L, requestCaptor.firstValue.workSpec.initialDelay) + } + @Test fun `synchronize cancels work no longer required`() { sut.synchronize( diff --git a/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt b/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt index e741447bf8..9625990a2a 100644 --- a/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt @@ -76,6 +76,22 @@ class SubscriptionsScreenTest { ) } + @Test + fun `proposed subscription review names the period accepting pays under a clock offset`() { + val proposed = subscription(PaykitRecurrenceUnit.Month).let { + it.copy( + lifecycleState = PaymentRequestLifecycleState.PROPOSED, + recurrence = it.recurrence.copy(startsAt = now, anchor = Instant.parse("2027-01-15T08:01:00Z")), + ) + } + val shiftedNow = Instant.parse("2027-02-15T08:00:00Z") + + assertEquals( + Instant.parse("2027-01-15T08:01:00Z"), + proposed.paymentDueOnAcceptance(shiftedNow, acceptedAt = now)?.billingPeriod?.endsAt, + ) + } + @Test fun `monthly cost includes paid active subscriptions only`() { val paidPeriod = PaykitBillingPeriod( diff --git a/app/src/test/java/to/bitkit/utils/SubscriptionClockOffsetTest.kt b/app/src/test/java/to/bitkit/utils/SubscriptionClockOffsetTest.kt new file mode 100644 index 0000000000..6690f3ff05 --- /dev/null +++ b/app/src/test/java/to/bitkit/utils/SubscriptionClockOffsetTest.kt @@ -0,0 +1,56 @@ +package to.bitkit.utils + +import org.junit.After +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant + +class SubscriptionClockOffsetTest { + private val date = Instant.parse("2027-01-15T08:00:00Z") + + @After + fun tearDown() { + SubscriptionClockOffset.setOffsetDays(0) + } + + @Test + fun `offset is off by default`() { + assertEquals(date, SubscriptionClockOffset.subscriptionDate(date, offsetDays = 0, isAvailable = true)) + } + + @Test + fun `offset moves the subscription date by whole days`() { + assertEquals( + date + 31.days, + SubscriptionClockOffset.subscriptionDate(date, offsetDays = 31, isAvailable = true), + ) + } + + @Test + fun `offset is ignored when unavailable`() { + assertEquals(date, SubscriptionClockOffset.subscriptionDate(date, offsetDays = 31, isAvailable = false)) + } + + @Test + fun `offset is clamped to the supported range`() { + SubscriptionClockOffset.setOffsetDays(-5) + assertEquals(0, SubscriptionClockOffset.offsetDays) + + SubscriptionClockOffset.setOffsetDays(10_000) + assertEquals(SubscriptionClockOffset.OFFSET_DAYS_RANGE.last, SubscriptionClockOffset.offsetDays) + } + + @Test + fun `subscription clock follows the base clock and the current offset`() { + val base = object : Clock { + override fun now(): Instant = date + } + val subscriptionClock = SubscriptionClockOffset.subscriptionClock(base) + + SubscriptionClockOffset.setOffsetDays(7) + + assertEquals(SubscriptionClockOffset.subscriptionDate(date, offsetDays = 7), subscriptionClock.now()) + } +} diff --git a/changelog.d/next/1341.added.md b/changelog.d/next/1341.added.md new file mode 100644 index 0000000000..5d1ef257c7 --- /dev/null +++ b/changelog.d/next/1341.added.md @@ -0,0 +1 @@ +Bitkit can move subscription renewals forward from Dev Settings for testing.