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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1287,7 +1287,7 @@ struct AppScene: View {
$0.paymentRequestId == target.paymentRequestId &&
PubkyPublicKeyFormat.matches($0.counterparty, target.counterparty) &&
$0.counterpartyReceiverPath == target.counterpartyReceiverPath &&
$0.isActive(at: Date())
$0.isActive(at: DemoClock.subscriptionNow())
}) {
PaykitSubscriptionNotificationTargetStore.clear()
}
Expand Down
70 changes: 43 additions & 27 deletions Bitkit/Services/PaykitPaymentRequestService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ struct PaykitSubscriptionDraft: Hashable {
name: "",
description: "",
frequency: .month,
expiresAt: Date().addingTimeInterval(7 * 24 * 60 * 60),
expiresAt: DemoClock.subscriptionNow().addingTimeInterval(7 * 24 * 60 * 60),
iconData: nil
)
}
Expand Down Expand Up @@ -504,13 +504,15 @@ extension PaykitSdkService: PaykitPaymentRequestSdkHandling {}
struct PaykitPaymentRequestService {
private let sdk: any PaykitPaymentRequestSdkHandling
private let now: @Sendable () -> Date
private let subscriptionNow: @Sendable () -> Date
private let isPrivatePaymentPublishingEnabled: @Sendable () -> Bool
private let logWarning: @Sendable (String) -> Void
private let incomingRejectionLog = IncomingPaykitPaymentRequestRejectionLog()

init(
sdk: any PaykitPaymentRequestSdkHandling = PaykitSdkService.shared,
now: @escaping @Sendable () -> Date = { Date() },
subscriptionNow: (@Sendable () -> Date)? = nil,
isPrivatePaymentPublishingEnabled: @escaping @Sendable () -> Bool = {
UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey)
},
Expand All @@ -520,6 +522,7 @@ struct PaykitPaymentRequestService {
) {
self.sdk = sdk
self.now = now
self.subscriptionNow = subscriptionNow ?? { DemoClock.subscriptionDate(from: now()) }
self.isPrivatePaymentPublishingEnabled = isPrivatePaymentPublishingEnabled
self.logWarning = logWarning
}
Expand Down Expand Up @@ -662,7 +665,7 @@ struct PaykitPaymentRequestService {
validateBeforeProposing: @MainActor () throws -> Void
) async throws -> PaykitSubscription {
let acceptedPaymentEndpointIdentifiers = Self.acceptedPaymentEndpointIdentifiers()
let validationDate = now()
let validationDate = subscriptionNow()
let name = draft.name.trimmingCharacters(in: .whitespacesAndNewlines)
guard draft.amountSats > 0,
!name.isEmpty,
Expand Down Expand Up @@ -700,7 +703,7 @@ struct PaykitPaymentRequestService {
guard Self.acceptedPaymentEndpointIdentifiers() == acceptedPaymentEndpointIdentifiers else {
throw PaykitPaymentRequestError.requestUnavailable
}
let proposalDate = now()
let proposalDate = subscriptionNow()
guard draft.expiresAt > proposalDate else {
throw PaykitPaymentRequestError.requestExpired
}
Expand Down Expand Up @@ -805,7 +808,7 @@ struct PaykitPaymentRequestService {
}

func accept(_ subscription: PaykitSubscription) async throws -> PaykitSubscription {
guard subscription.isProposalActionable(at: now()) else {
guard subscription.isProposalActionable(at: subscriptionNow()) else {
throw PaykitPaymentRequestError.requestExpired
}

Expand All @@ -822,7 +825,7 @@ struct PaykitPaymentRequestService {
}

func cancel(_ subscription: PaykitSubscription) async throws -> PaykitSubscription {
guard subscription.canCancel(at: now()) else {
guard subscription.canCancel(at: subscriptionNow()) else {
throw PaykitPaymentRequestError.requestUnavailable
}

Expand Down Expand Up @@ -1015,6 +1018,7 @@ final class PaykitPaymentRequestManager {
PaykitSubscription.ID
) async throws -> Set<PaykitPaymentRequest.ID>
private let now: @Sendable () -> Date
private let subscriptionNow: @Sendable () -> Date
private let logWarning: @Sendable (String) -> Void
private let isAvailable: @MainActor () -> Bool
private var processingRequestIds: Set<PaykitPaymentRequest.ID> = []
Expand Down Expand Up @@ -1072,19 +1076,22 @@ final class PaykitPaymentRequestManager {
)
},
now: @escaping @Sendable () -> Date = { Date() },
subscriptionNow: (@Sendable () -> Date)? = nil,
isAvailable: @escaping @MainActor () -> Bool = { PaykitFeatureFlags.isUIEnabled },
logWarning: @escaping @Sendable (String) -> Void = {
Logger.warn($0, context: "PaykitPaymentRequest")
}
) {
self.service = service ?? PaykitPaymentRequestService(now: now, logWarning: logWarning)
let subscriptionNow = subscriptionNow ?? { DemoClock.subscriptionDate(from: now()) }
self.service = service ?? PaykitPaymentRequestService(now: now, subscriptionNow: subscriptionNow, logWarning: logWarning)
self.presentationStore = presentationStore
self.subscriptionStateStore = subscriptionStateStore
self.subscriptionNotificationScheduler = subscriptionNotificationScheduler
self.completedPaymentProofKinds = completedPaymentProofKinds
self.inFlightPaymentRequestIds = inFlightPaymentRequestIds
self.protectedRequestIdsForSubscriptionCancellation = protectedRequestIdsForSubscriptionCancellation
self.now = now
self.subscriptionNow = subscriptionNow
self.isAvailable = isAvailable
self.logWarning = logWarning
}
Expand Down Expand Up @@ -1256,7 +1263,7 @@ final class PaykitPaymentRequestManager {
pendingRequestIds: Set(pendingRequests.map(\.id)),
payerIdentity: activeIdentity,
notificationsEnabled: enabled,
now: now()
now: subscriptionNow()
)
}

Expand Down Expand Up @@ -1345,20 +1352,21 @@ final class PaykitPaymentRequestManager {
func requestSubscriptionPresentation(_ subscription: PaykitSubscription) {
guard subscriptions.contains(where: { $0.id == subscription.id }),
subscription.isPayer,
subscription.isProposalVisible(at: now()),
subscription.isProposalVisible(at: subscriptionNow()),
!isProcessingSubscription
else { return }
requestedSubscriptionProposalId = subscription.id
}

func subscriptionProposalForPresentation() -> PaykitSubscription? {
let date = subscriptionNow()
if let requestedSubscriptionProposalId {
return subscriptions.first {
$0.id == requestedSubscriptionProposalId && $0.isPayer && $0.isProposalVisible(at: now())
$0.id == requestedSubscriptionProposalId && $0.isPayer && $0.isProposalVisible(at: date)
}
}
return subscriptions.first {
$0.isPayer && $0.isProposalVisible(at: now()) && !presentedSubscriptionProposalIds.contains($0.id)
$0.isPayer && $0.isProposalVisible(at: date) && !presentedSubscriptionProposalIds.contains($0.id)
}
}

Expand Down Expand Up @@ -1400,7 +1408,7 @@ final class PaykitPaymentRequestManager {
guard let current = subscriptions.first(where: { $0.id == subscription.id }),
current == subscription,
current.isPayer,
current.isProposalActionable(at: now()),
current.isProposalActionable(at: subscriptionNow()),
let activeIdentity
else { throw PaykitPaymentRequestError.requestUnavailable }

Expand All @@ -1412,7 +1420,7 @@ final class PaykitPaymentRequestManager {
}
}
let acceptedSubscription = try await service.accept(current)
let acceptanceDate = now()
let acceptanceDate = subscriptionNow()
guard actionGeneration == stateGeneration,
PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity)
else { return nil }
Expand All @@ -1431,7 +1439,7 @@ final class PaykitPaymentRequestManager {
func cancel(_ subscription: PaykitSubscription) async throws {
guard !isProcessingSubscription else { throw PaykitPaymentRequestError.operationInProgress }
guard let current = subscriptions.first(where: { $0.id == subscription.id }),
current.canCancel(at: now()),
current.canCancel(at: subscriptionNow()),
let activeIdentity
else {
throw PaykitPaymentRequestError.requestUnavailable
Expand Down Expand Up @@ -1459,7 +1467,7 @@ final class PaykitPaymentRequestManager {
guard actionGeneration == stateGeneration,
PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity)
else { return }
await applyCommittedSubscription(canceledSubscription, at: now())
await applyCommittedSubscription(canceledSubscription, at: subscriptionNow())
invalidateRefresh()
await refresh()
}
Expand Down Expand Up @@ -1713,23 +1721,24 @@ final class PaykitPaymentRequestManager {
PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity)
else { return }
let refreshDate = now()
let subscriptionDate = subscriptionNow()
let handledRequestedExpirationId = recordRequestedPresentationExpiration(at: refreshDate)
let previousPending = pendingRequests
subscriptions = snapshot.subscriptions.map { $0.withExpiredLifecycle(at: refreshDate) }
subscriptions = snapshot.subscriptions.map { $0.withExpiredLifecycle(at: subscriptionDate) }
let visibleProposalIds = Set(subscriptions.filter {
$0.isPayer && $0.isProposalVisible(at: refreshDate)
$0.isPayer && $0.isProposalVisible(at: subscriptionDate)
}.map(\.id))
presentedSubscriptionProposalIds.formIntersection(visibleProposalIds)
for subscription in subscriptions
where subscription.isPayer &&
subscription.wasAccepted &&
subscriptionAcceptedAt[subscription.id] == nil
{
subscriptionAcceptedAt[subscription.id] = subscription.paidPeriods.map(\.startsAt).min() ?? subscription.createdAt ?? refreshDate
subscriptionAcceptedAt[subscription.id] = subscription.paidPeriods.map(\.startsAt).min() ?? subscription.createdAt ?? subscriptionDate
}
let recurringRequestsBySubscription = subscriptions.filter(\.isPayer).map { subscription in
let requests: [PaykitPaymentRequest] = if let acceptedAt = subscriptionAcceptedAt[subscription.id] {
subscription.requests(through: refreshDate, acceptedAt: acceptedAt)
subscription.requests(through: subscriptionDate, acceptedAt: acceptedAt)
} else {
[]
}
Expand Down Expand Up @@ -1796,7 +1805,7 @@ final class PaykitPaymentRequestManager {
pendingRequestIds: synchronizedRequestIds,
payerIdentity: activeIdentity,
notificationsEnabled: SettingsViewModel.shared.enableNotifications,
now: refreshDate
now: subscriptionDate
)
let currentRequestIds = Set(pendingRequests.map(\.id))
presentedRequestIds.formIntersection(currentRequestIds)
Expand Down Expand Up @@ -1925,15 +1934,16 @@ final class PaykitPaymentRequestManager {
handledRequestedExpirationId: PaykitPaymentRequest.ID? = nil
) {
let date = at ?? now()
let subscriptionDate = subscriptionNow()
recordRequestedPresentationExpiration(at: date, excluding: handledRequestedExpirationId)
pendingRequests.removeAll { $0.isExpired(at: date) }
subscriptions = subscriptions.map { $0.withExpiredLifecycle(at: date) }
subscriptions = subscriptions.map { $0.withExpiredLifecycle(at: subscriptionDate) }
presentedSubscriptionProposalIds.formIntersection(
Set(subscriptions.filter { $0.isPayer && $0.isProposalVisible(at: date) }.map(\.id))
Set(subscriptions.filter { $0.isPayer && $0.isProposalVisible(at: subscriptionDate) }.map(\.id))
)
persistSubscriptionState()
if requestedSubscriptionProposalId.map({ id in
subscriptions.contains { $0.id == id && $0.isProposalVisible(at: date) }
subscriptions.contains { $0.id == id && $0.isProposalVisible(at: subscriptionDate) }
}) == false {
requestedSubscriptionProposalId = nil
}
Expand Down Expand Up @@ -1990,14 +2000,20 @@ final class PaykitPaymentRequestManager {
expirationTask?.cancel()
expirationTask = nil

let requestExpirations = pendingRequests.filter { $0.lifecycleState == .proposed }.compactMap(\.expiresAt)
let subscriptionExpirations = subscriptions.filter {
let date = now()
let subscriptionDate = subscriptionNow()
let requestDelays = pendingRequests.filter { $0.lifecycleState == .proposed }.compactMap(\.expiresAt).map {
$0.timeIntervalSince(date)
}
let subscriptionDelays = subscriptions.filter {
$0.isProposal || $0.lifecycleState == .activeRecurring
}.flatMap {
[$0.proposalExpiresAt, $0.recurrence.endsAt].compactMap { $0 }
}.filter { $0 > now() }
guard let nextExpiration = (requestExpirations + subscriptionExpirations).min() else { return }
let delay = max(0, nextExpiration.timeIntervalSince(now()))
}.filter { $0 > subscriptionDate }.map {
$0.timeIntervalSince(subscriptionDate)
}
guard let nextDelay = (requestDelays + subscriptionDelays).min() else { return }
let delay = max(0, nextDelay)
expirationTask = Task { [weak self] in
do {
try await Task.sleep(for: .seconds(delay))
Expand Down
37 changes: 37 additions & 0 deletions Bitkit/Utilities/DemoClock.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Foundation

/// Dev-only offset that moves Paykit subscription scheduling forward, so a recorded demo can show a renewal
/// without waiting a whole billing period. It covers subscription proposals, acceptance, due periods, renewal
/// dates and due notifications. One-time payment requests, invoices, payments and allowance checks keep real time.
enum DemoClock {
static let offsetDaysKey = "demoClockOffsetDays"
static let offsetDaysRange = 0 ... 400
static let offsetDaysPresets = [0, 1, 7, 30, 31, 62, 365]

static var isAvailable: Bool {
Env.isDebug
}

static func offsetDays(defaults: UserDefaults = .standard, isAvailable: Bool = Self.isAvailable) -> Int {
guard isAvailable else { return 0 }
return clampedOffsetDays(defaults.integer(forKey: offsetDaysKey))
}

static func clampedOffsetDays(_ days: Int) -> Int {
min(max(days, offsetDaysRange.lowerBound), offsetDaysRange.upperBound)
}

static func subscriptionDate(
from date: Date,
defaults: UserDefaults = .standard,
isAvailable: Bool = Self.isAvailable
) -> Date {
let days = offsetDays(defaults: defaults, isAvailable: isAvailable)
guard days != 0 else { return date }
return date.addingTimeInterval(TimeInterval(days) * 24 * 60 * 60)
}

static func subscriptionNow() -> Date {
subscriptionDate(from: Date())
}
}
27 changes: 27 additions & 0 deletions Bitkit/Views/Settings/DevSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ struct DevSettingsView: View {
@AppStorage(PrivatePaykitService.publishingEnabledKey) private var sharesPrivatePaykitEndpoints = false
@AppStorage(PublicPaykitService.publishingEnabledKey) private var sharesPublicPaykitEndpoints = false
@AppStorage(BoltzService.savingsSwapEnabledKey) private var isSavingsSwapEnabled = false
@AppStorage(DemoClock.offsetDaysKey) private var demoClockOffsetDays = 0

@EnvironmentObject var app: AppViewModel
@EnvironmentObject var activity: ActivityListViewModel
Expand Down Expand Up @@ -102,6 +103,10 @@ struct DevSettingsView: View {
),
testIdentifier: "PaykitUiToggle"
)

if DemoClock.isAvailable {
demoClockOffsetMenu
}
}

Button {
Expand Down Expand Up @@ -218,6 +223,28 @@ struct DevSettingsView: View {
}
}

private var demoClockOffsetMenu: some View {
Menu {
ForEach(DemoClock.offsetDaysPresets, id: \.self) { days in
Button(Self.demoClockOffsetLabel(days)) {
demoClockOffsetDays = days
}
.accessibilityIdentifier("DemoClockOffset-\(days)")
}
} label: {
SettingsRow(
title: "Demo clock offset (days)",
rightText: Self.demoClockOffsetLabel(DemoClock.clampedOffsetDays(demoClockOffsetDays)),
rightIcon: nil
)
}
.accessibilityIdentifier("DemoClockOffset")
}

private static func demoClockOffsetLabel(_ days: Int) -> String {
days == 0 ? "Off" : "\(days)"
}

@MainActor
private func disablePaykitUI() async {
isPaykitUIEnabled = false
Expand Down
4 changes: 2 additions & 2 deletions Bitkit/Views/Subscriptions/CreateSubscriptionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ struct SubscriptionRecipientView: View {
Menu {
ForEach(PaymentRequestExpiration.allCases) { expiration in
Button(expiration.description) {
draft.expiresAt = expiration.date(from: Date())
draft.expiresAt = expiration.date(from: DemoClock.subscriptionNow())
}
}
} label: {
Expand Down Expand Up @@ -326,7 +326,7 @@ struct SubscriptionProposalSentView: View {
}
SubscriptionRow(
subscription: subscription,
now: Date(),
now: DemoClock.subscriptionNow(),
subtitle: subscription.recurrence.subscriptionFrequencyLabel
)
.padding(.top, 16)
Expand Down
Loading
Loading