From 72ed6831fdcd60aea87ff1b3d9a5334e95eda386 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 00:26:07 +0200 Subject: [PATCH 1/2] feat: add a demo clock for subscription renewals Dev Settings > PAYKIT > Demo clock offset (days) moves Paykit subscription scheduling forward so a recorded demo can show a renewal without waiting a billing period. It covers proposal anchors and expiry, acceptance, due periods, renewal dates and due notifications. One-time requests, invoices, payments and allowance checks keep real time. Off by default and ignored outside Debug. --- Bitkit/AppScene.swift | 2 +- .../PaykitPaymentRequestService.swift | 70 ++++++++----- Bitkit/Utilities/DemoClock.swift | 37 +++++++ Bitkit/Views/Settings/DevSettingsView.swift | 27 +++++ .../CreateSubscriptionView.swift | 4 +- .../Subscriptions/SubscriptionsView.swift | 20 ++-- BitkitTests/DemoClockTests.swift | 53 ++++++++++ .../PaykitPaymentRequestServiceTests.swift | 99 +++++++++++++++++++ 8 files changed, 272 insertions(+), 40 deletions(-) create mode 100644 Bitkit/Utilities/DemoClock.swift create mode 100644 BitkitTests/DemoClockTests.swift diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index e3a92c646..47d3b8b23 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -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() } diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index b1630d55c..c80c9b9fc 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -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 ) } @@ -504,6 +504,7 @@ 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() @@ -511,6 +512,7 @@ struct PaykitPaymentRequestService { 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) }, @@ -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 } @@ -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, @@ -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 } @@ -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 } @@ -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 } @@ -1015,6 +1018,7 @@ final class PaykitPaymentRequestManager { PaykitSubscription.ID ) async throws -> Set 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 = [] @@ -1072,12 +1076,14 @@ 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 @@ -1085,6 +1091,7 @@ final class PaykitPaymentRequestManager { self.inFlightPaymentRequestIds = inFlightPaymentRequestIds self.protectedRequestIdsForSubscriptionCancellation = protectedRequestIdsForSubscriptionCancellation self.now = now + self.subscriptionNow = subscriptionNow self.isAvailable = isAvailable self.logWarning = logWarning } @@ -1256,7 +1263,7 @@ final class PaykitPaymentRequestManager { pendingRequestIds: Set(pendingRequests.map(\.id)), payerIdentity: activeIdentity, notificationsEnabled: enabled, - now: now() + now: subscriptionNow() ) } @@ -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) } } @@ -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 } @@ -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 } @@ -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 @@ -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() } @@ -1713,11 +1721,12 @@ 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 @@ -1725,11 +1734,11 @@ final class PaykitPaymentRequestManager { 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 { [] } @@ -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) @@ -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 } @@ -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)) diff --git a/Bitkit/Utilities/DemoClock.swift b/Bitkit/Utilities/DemoClock.swift new file mode 100644 index 000000000..d1d3aed63 --- /dev/null +++ b/Bitkit/Utilities/DemoClock.swift @@ -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()) + } +} diff --git a/Bitkit/Views/Settings/DevSettingsView.swift b/Bitkit/Views/Settings/DevSettingsView.swift index 525245834..efea2f5d9 100644 --- a/Bitkit/Views/Settings/DevSettingsView.swift +++ b/Bitkit/Views/Settings/DevSettingsView.swift @@ -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 @@ -102,6 +103,10 @@ struct DevSettingsView: View { ), testIdentifier: "PaykitUiToggle" ) + + if DemoClock.isAvailable { + demoClockOffsetMenu + } } Button { @@ -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 diff --git a/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift b/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift index d8f0b3f83..28b14219d 100644 --- a/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift +++ b/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift @@ -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: { @@ -326,7 +326,7 @@ struct SubscriptionProposalSentView: View { } SubscriptionRow( subscription: subscription, - now: Date(), + now: DemoClock.subscriptionNow(), subtitle: subscription.recurrence.subscriptionFrequencyLabel ) .padding(.top, 16) diff --git a/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift index 40eef3db1..04f2f4350 100644 --- a/Bitkit/Views/Subscriptions/SubscriptionsView.swift +++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift @@ -37,7 +37,7 @@ struct SubscriptionsView: View { @Environment(PaykitPaymentRequestManager.self) private var paymentRequests @State private var selectedTab = Tab.overview - @State private var now = Date() + @State private var now = DemoClock.subscriptionNow() private let showPayments: Bool init(showPayments: Bool = false) { @@ -103,7 +103,7 @@ struct SubscriptionsView: View { } catch { return } - now = Date() + now = DemoClock.subscriptionNow() } } @@ -367,7 +367,7 @@ struct SubscriptionDetailView: View { @Environment(PaykitPaymentRequestManager.self) private var paymentRequests let id: PaykitSubscription.ID - @State private var now = Date() + @State private var now = DemoClock.subscriptionNow() private var subscription: PaykitSubscription? { paymentRequests.subscriptions.first { $0.id == id } @@ -421,7 +421,7 @@ struct SubscriptionDetailView: View { } catch { return } - now = Date() + now = DemoClock.subscriptionNow() } } @@ -549,7 +549,7 @@ struct SubscriptionSheet: View { @State private var route: SubscriptionSheetItem.Route @State private var previousRoute: SubscriptionSheetItem.Route? - @State private var now = Date() + @State private var now = DemoClock.subscriptionNow() @State private var isAccepting = false @State private var creationDraft = PaykitSubscriptionDraft.empty @State private var selectedCreationTarget: PaykitPaymentRequestTarget? @@ -601,13 +601,13 @@ struct SubscriptionSheet: View { } .task { if case let .review(subscription) = route { - now = Date() + now = DemoClock.subscriptionNow() paymentRequests.markSubscriptionProposalPresented(subscription) } } .onChange(of: route) { _, route in guard case let .review(subscription) = route else { return } - now = Date() + now = DemoClock.subscriptionNow() paymentRequests.markSubscriptionProposalPresented(subscription) } .onChange(of: paymentRequests.subscriptions) { @@ -615,7 +615,7 @@ struct SubscriptionSheet: View { !paymentRequests.isProcessingSubscription, case let .review(subscription) = route, !paymentRequests.subscriptions.contains(where: { - $0.id == subscription.id && $0.isProposalVisible(at: Date()) + $0.id == subscription.id && $0.isProposalVisible(at: DemoClock.subscriptionNow()) }) else { return } sheets.hideSheetIfActive(.subscription, reason: "Subscription proposal is no longer available") @@ -623,11 +623,11 @@ struct SubscriptionSheet: View { .task(id: reviewTransitionDate) { guard let reviewTransitionDate else { return } do { - try await Task.sleep(for: .seconds(max(0, reviewTransitionDate.timeIntervalSinceNow))) + try await Task.sleep(for: .seconds(max(0, reviewTransitionDate.timeIntervalSince(DemoClock.subscriptionNow())))) } catch { return } - now = Date() + now = DemoClock.subscriptionNow() } .interactiveDismissDisabled(isAccepting || paymentRequests.isCreatingRequest) } diff --git a/BitkitTests/DemoClockTests.swift b/BitkitTests/DemoClockTests.swift new file mode 100644 index 000000000..022689994 --- /dev/null +++ b/BitkitTests/DemoClockTests.swift @@ -0,0 +1,53 @@ +@testable import Bitkit +import XCTest + +final class DemoClockTests: XCTestCase { + private var defaults: UserDefaults! + private var suiteName: String! + + override func setUp() { + super.setUp() + suiteName = "DemoClockTests-\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + func testOffsetIsOffByDefault() { + let date = Date(timeIntervalSince1970: 1_800_000_000) + + XCTAssertEqual(DemoClock.offsetDays(defaults: defaults, isAvailable: true), 0) + XCTAssertEqual(DemoClock.subscriptionDate(from: date, defaults: defaults, isAvailable: true), date) + } + + func testOffsetMovesSubscriptionDateByWholeDays() { + let date = Date(timeIntervalSince1970: 1_800_000_000) + defaults.set(31, forKey: DemoClock.offsetDaysKey) + + XCTAssertEqual( + DemoClock.subscriptionDate(from: date, defaults: defaults, isAvailable: true), + date.addingTimeInterval(31 * 24 * 60 * 60) + ) + } + + func testOffsetIsIgnoredWhenUnavailable() { + let date = Date(timeIntervalSince1970: 1_800_000_000) + defaults.set(31, forKey: DemoClock.offsetDaysKey) + + XCTAssertEqual(DemoClock.offsetDays(defaults: defaults, isAvailable: false), 0) + XCTAssertEqual(DemoClock.subscriptionDate(from: date, defaults: defaults, isAvailable: false), date) + } + + func testOffsetIsClampedToSupportedRange() { + defaults.set(-5, forKey: DemoClock.offsetDaysKey) + XCTAssertEqual(DemoClock.offsetDays(defaults: defaults, isAvailable: true), 0) + + defaults.set(10000, forKey: DemoClock.offsetDaysKey) + XCTAssertEqual(DemoClock.offsetDays(defaults: defaults, isAvailable: true), DemoClock.offsetDaysRange.upperBound) + } +} diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 5ddcac728..a9bccf6dd 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -836,6 +836,101 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(manager.pendingRequests.isEmpty) } + func testDemoClockOffsetSurfacesNextSubscriptionPeriodAndKeepsOneTimeRequestsOnRealTime() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let oneMonthLater = now.addingTimeInterval(31 * 24 * 60 * 60) + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-15T08:00:00Z", + anchor: "2027-01-15T08:00:00Z", + endsAt: nil + ) + let firstPeriod = BillingPeriod(startsAt: "2027-01-15T08:00:00Z", endsAt: "2027-02-15T08:00:00Z") + let records = try [ + paymentRequestRecord( + id: "recurring", + state: .activeRecurring, + recurrence: recurrence, + acceptedEventId: "accepted", + paymentProofs: [paymentProofRecord( + endpoint: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning, + billingPeriod: firstPeriod + )] + ), + paymentRequestRecord(id: "one-time", expiresAt: "2027-01-22T08:00:00Z"), + ] + + let realTimeManager = try paymentRequestManager( + sdk: PaymentRequestSdkMock(records: records), + clock: PaymentRequestTestClock(now) + ) + await realTimeManager.refresh() + XCTAssertEqual(realTimeManager.pendingRequests.map(\.paymentRequestId), ["one-time"]) + + let demoManager = try paymentRequestManager( + sdk: PaymentRequestSdkMock(records: records), + clock: PaymentRequestTestClock(now), + subscriptionClock: PaymentRequestTestClock(oneMonthLater) + ) + await demoManager.refresh() + + let renewal = try XCTUnwrap(demoManager.pendingRequests.first { $0.paymentRequestId == "recurring" }) + XCTAssertEqual(renewal.billingPeriod?.sdkValue, BillingPeriod(startsAt: "2027-02-15T08:00:00Z", endsAt: "2027-03-15T08:00:00Z")) + XCTAssertTrue(demoManager.pendingRequests.contains { $0.paymentRequestId == "one-time" }) + let subscription = try XCTUnwrap(demoManager.subscriptions.first) + XCTAssertEqual(subscription.paidPeriods, [PaykitBillingPeriod(sdkPeriod: firstPeriod)]) + XCTAssertTrue(subscription.isActive(at: oneMonthLater)) + } + + func testDemoClockOffsetAnchorsProposedSubscriptionInDemoTime() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let oneMonthLater = now.addingTimeInterval(31 * 24 * 60 * 60) + let publicKey = "pubky\(String(repeating: "y", count: 52))" + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)], + receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]] + ) + try await sdk.setProposalResult(paymentRequestRecord( + id: "creator-proposal", + counterparty: publicKey, + counterpartyReceiverPath: PaykitReceiverPath.wallet, + role: .payee + )) + let manager = paymentRequestManager( + sdk: sdk, + clock: PaymentRequestTestClock(now), + subscriptionClock: PaymentRequestTestClock(oneMonthLater) + ) + await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) + let target = try XCTUnwrap(manager.eligibleTargets.first) + var draft = PaykitSubscriptionDraft( + amountSats: 2000, + name: "Tee Club", + description: "", + frequency: .month, + expiresAt: now.addingTimeInterval(7 * 24 * 60 * 60), + iconData: nil + ) + + do { + _ = try await manager.proposeSubscription(draft, to: target) + XCTFail("A proposal that expires before the demo clock must be refused") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestExpired) + } + + draft.expiresAt = oneMonthLater.addingTimeInterval(7 * 24 * 60 * 60) + _ = try await manager.proposeSubscription(draft, to: target) + + let snapshot = await sdk.snapshot() + let proposed = try XCTUnwrap(snapshot.proposedRequests.first) + XCTAssertEqual(proposed.recurrence?.startsAt, timestamp(oneMonthLater)) + XCTAssertEqual(proposed.recurrence?.anchor, timestamp(oneMonthLater)) + } + func testSubscriptionPaymentCanBeReopenedForManualRetry() async throws { let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) let recurrence = PaymentRequestRecurrence( @@ -3131,6 +3226,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { private func paymentRequestManager( sdk: PaymentRequestSdkMock, clock: PaymentRequestTestClock = PaymentRequestTestClock(Date()), + subscriptionClock: PaymentRequestTestClock? = nil, subscriptionStateStore: PaymentRequestSubscriptionStateMemoryStore = PaymentRequestSubscriptionStateMemoryStore(), subscriptionNotificationScheduler: PaykitSubscriptionNotificationScheduler = PaykitSubscriptionNotificationScheduler(), isPrivatePaymentPublishingEnabled: Bool = true, @@ -3139,10 +3235,12 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { protectedRequestIdsForSubscriptionCancellation: Set = [] ) -> PaykitPaymentRequestManager { let now: @Sendable () -> Date = { clock.now() } + let subscriptionNow: (@Sendable () -> Date)? = subscriptionClock.map { subscriptionClock in { subscriptionClock.now() } } let manager = PaykitPaymentRequestManager( service: PaykitPaymentRequestService( sdk: sdk, now: now, + subscriptionNow: subscriptionNow, isPrivatePaymentPublishingEnabled: { isPrivatePaymentPublishingEnabled }, logWarning: { _ in } ), @@ -3153,6 +3251,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { inFlightPaymentRequestIds: { _ in inFlightPaymentRequestIds }, protectedRequestIdsForSubscriptionCancellation: { _, _ in protectedRequestIdsForSubscriptionCancellation }, now: now, + subscriptionNow: subscriptionNow, isAvailable: { true }, logWarning: { _ in } ) From 765e416780e3bf9a1b59de90ee39cde60476f543 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 25 Sep 2026 00:23:58 +0200 Subject: [PATCH 2/2] docs: add the second billing period journey --- .../subscriptions/second-billing-period.xml | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 journeys/subscriptions/second-billing-period.xml diff --git a/journeys/subscriptions/second-billing-period.xml b/journeys/subscriptions/second-billing-period.xml new file mode 100644 index 000000000..aafab969e --- /dev/null +++ b/journeys/subscriptions/second-billing-period.xml @@ -0,0 +1,23 @@ + + + The next period of a monthly subscription comes due without waiting a month. Dev Settings carries + a demo clock offset, in debug builds only, that moves subscription scheduling forward by a number + of days: proposal anchors and expiry, acceptance, due periods, renewal dates and due + notifications. One-time requests, invoices, payments and allowances keep real time, and the offset + is Off by default. Requires the active "Journey Sub" from review-and-subscribe.xml with its first + period paid on acceptance, and both instances on a debug build. + + + Launch the E2E Bitkit app on both instances with "Journey Sub" active and its first period paid, per review-and-subscribe.xml + On the creator instance, open the menu, navigate to Settings (id "DrawerSettings"), then Dev Settings, and scroll to the PAYKIT section + Verify the "Demo clock offset (days)" row (id "DemoClockOffset") reads "Off" + Tap the row and pick 31 (id "DemoClockOffset-31"); verify the row now reads "31" + Repeat the two previous steps on the payer instance + On the payer instance, return home and verify within about ten seconds that the next period arrives as a Payment Request for "Journey Sub" over 5,000 sats, opening the Swipe To Pay sheet or waiting in the bell (id "PaymentRequestsBell") + Pay it and verify Bitcoin Sent + On the creator instance, verify the payment arrives, as the Received Instant Bitcoin sheet or a new row in Activity + On the payer instance, open the drawer menu, tap Subscriptions and tap the "Journey Sub" row + Verify the PAYMENTS section lists two payments, today's and one dated a month later, and that RENEWS moved a further month ahead + On both instances, open Dev Settings again and set the demo clock offset back to Off (id "DemoClockOffset-0"); verify the row reads "Off" + +