From f89ecb04dc26c6e8c40ed6051c4e5469dbfc9ce6 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 26 Aug 2026 13:02:49 -0500 Subject: [PATCH 1/2] feat: add Paykit subscription payments --- Bitkit/AppScene.swift | 112 ++- .../subscription-clock.imageset/Contents.json | 21 + .../subscription-clock.png | Bin 0 -> 85117 bytes Bitkit/BitkitApp.swift | 73 +- Bitkit/Components/Button/Button.swift | 3 +- .../Button/SecondaryButtonView.swift | 18 +- Bitkit/Components/DrawerView.swift | 20 +- Bitkit/Components/LabeledDetailCell.swift | 26 + Bitkit/Components/SegmentedControl.swift | 18 +- Bitkit/MainNavView.swift | 23 +- Bitkit/Managers/TagManager.swift | 11 + .../Localization/en.lproj/Localizable.strings | 63 +- .../Services/PaykitPaymentProofService.swift | 319 ++++++- .../PaykitPaymentRequestService.swift | 560 ++++++++++- Bitkit/Services/PaykitSubscription.swift | 706 ++++++++++++++ .../PrivatePaykitService+Payments.swift | 10 + Bitkit/Services/PubkyService.swift | 16 + Bitkit/Utilities/Keychain.swift | 2 + .../Utilities/PaymentNavigationHelper.swift | 56 ++ Bitkit/ViewModels/AppViewModel.swift | 41 +- Bitkit/ViewModels/NavigationViewModel.swift | 4 +- Bitkit/ViewModels/SheetViewModel.swift | 13 + Bitkit/ViewModels/WalletViewModel.swift | 8 +- Bitkit/Views/Contacts/ContactDetailView.swift | 90 +- .../CreatePaymentRequestView.swift | 462 ++++++---- .../PaymentRequests/PaymentRequestsView.swift | 401 ++++++-- .../Subscriptions/SubscriptionsView.swift | 870 ++++++++++++++++++ Bitkit/Views/Wallets/Receive/ReceiveQr.swift | 26 +- .../Views/Wallets/Receive/ReceiveSheet.swift | 45 +- .../InitialSubscriptionPaymentProgress.swift | 27 + .../Views/Wallets/Send/LnurlPayConfirm.swift | 101 +- .../Wallets/Send/SendConfirmationView.swift | 211 +++-- Bitkit/Views/Wallets/Send/SendFailure.swift | 16 +- .../Wallets/Send/SendPendingScreen.swift | 20 +- Bitkit/Views/Wallets/Send/SendSheet.swift | 136 ++- Bitkit/Views/Wallets/Send/SendSuccess.swift | 23 +- .../PaykitPaymentProofServiceTests.swift | 347 ++++++- .../PaykitPaymentRequestServiceTests.swift | 853 ++++++++++++++++- .../next/paykit-subscriptions.added.md | 1 + 39 files changed, 5195 insertions(+), 557 deletions(-) create mode 100644 Bitkit/Assets.xcassets/Illustrations/subscription-clock.imageset/Contents.json create mode 100644 Bitkit/Assets.xcassets/Illustrations/subscription-clock.imageset/subscription-clock.png create mode 100644 Bitkit/Components/LabeledDetailCell.swift create mode 100644 Bitkit/Services/PaykitSubscription.swift create mode 100644 Bitkit/Views/Subscriptions/SubscriptionsView.swift create mode 100644 Bitkit/Views/Wallets/Send/InitialSubscriptionPaymentProgress.swift create mode 100644 changelog.d/next/paykit-subscriptions.added.md diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 9c5c18dab..58d70aaea 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -122,6 +122,10 @@ struct AppScene: View { } var body: some View { + appEventContent + } + + private var configuredContent: some View { mainContent .sheet( item: $sheets.forgotPinSheetItem, @@ -141,6 +145,7 @@ struct AppScene: View { .task(priority: .userInitiated, setupTask) .task(id: scenePhase) { await pollIncomingPaykitPaymentRequests() } .task(id: initialPaykitSyncGeneration) { await pollIncomingPaykitPaymentRequestsDuringInitialSync() } + .task { await handlePendingPaykitSubscriptionNotification() } .onChange(of: currency.hasStaleData) { _, newValue in handleCurrencyStaleData(newValue) } .onChange(of: wallet.walletExists) { _, newValue in handleWalletExistsChange(newValue) } .onChange(of: wallet.nodeLifecycleState) { _, newValue in handleNodeLifecycleChange(newValue) } @@ -200,13 +205,21 @@ struct AppScene: View { .environment(hwWalletManager) .environment(calculatorInputManager) .environment(paykitPaymentRequestManager) + } + + private var appEventContent: some View { + configuredContent .onChange(of: pubkyProfile.authState, initial: true) { _, authState in if authState == .authenticated, let pk = pubkyProfile.publicKey { paykitPaymentRequestManager.activate(identity: pk) Task { try? await contactsManager.loadContacts(for: pk) await refreshPrivateOnlyPaykitReceiverMarker() - await refreshIncomingPaykitPaymentRequests() + await refreshIncomingPaykitPaymentRequests(presentItems: false) + await handlePendingPaykitSubscriptionNotification() + if PaykitSubscriptionNotificationTargetStore.load() == nil { + await presentNextIncomingPaykitItem() + } if !PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true { await retryPendingPaykitEndpointRemoval() } @@ -236,6 +249,15 @@ struct AppScene: View { .onReceive(PrivatePaykitService.initialLinkBurstStartedPublisher) { initialPaykitSyncGeneration += 1 } + .onReceive(PaykitPaymentProofService.proofStateChangedPublisher) { + Task { await refreshIncomingPaykitPaymentRequests() } + } + .onReceive(PaykitPaymentProofService.onchainPaymentResolutionPublisher) { resolution in + Task { await associateResolvedPaykitOnchainPayment(resolution) } + } + .onReceive(NotificationCenter.default.publisher(for: .paykitSubscriptionPaymentDue)) { _ in + Task { await handlePendingPaykitSubscriptionNotification() } + } .onChange(of: sheets.activeSheetConfiguration?.id) { _, activeSheetId in guard activeSheetId == nil, !sheets.isReplacingSheet else { return } Task { @@ -244,7 +266,7 @@ struct AppScene: View { sheets.activeSheetConfiguration == nil, !sheets.isReplacingSheet else { return } - await presentNextIncomingPaykitPaymentRequest() + await presentNextIncomingPaykitItem() } } .onChange(of: paykitPaymentRequestManager.requestedPresentationId) { _, requestId in @@ -761,7 +783,7 @@ struct AppScene: View { } @discardableResult - private func refreshIncomingPaykitPaymentRequests() async -> Bool { + private func refreshIncomingPaykitPaymentRequests(presentItems: Bool = true) async -> Bool { guard PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true, pubkyProfile.authState == .authenticated @@ -774,10 +796,39 @@ struct AppScene: View { let previousRequests = paykitPaymentRequestManager.pendingRequests await paykitPaymentRequestManager.refreshEligibleTargets(savedPublicKeys: contactsManager.contacts.map(\.publicKey)) await paykitPaymentRequestManager.refresh() - await presentNextIncomingPaykitPaymentRequest() + if presentItems { + await presentNextIncomingPaykitItem() + } return paykitPaymentRequestManager.pendingRequests != previousRequests } + private func associateResolvedPaykitOnchainPayment(_ resolution: PaykitOnchainPaymentResolution) async { + guard let identity = pubkyProfile.publicKey, + PubkyPublicKeyFormat.matches(resolution.identity, identity) + else { return } + do { + _ = try await tryNTimes( + toTry: { + try? await activity.syncLdkNodePayments() + return try await activity.findActivity(byPaymentId: resolution.transactionId) + }, + times: 12, + interval: 2 + ) + try await activity.setContact( + resolution.requestId.counterparty, + forPaymentId: resolution.transactionId, + syncLdkPayments: false + ) + await PaykitPaymentProofService.shared.consumeOnchainPaymentResolution(resolution) + } catch { + Logger.warn( + "Failed to associate resolved Paykit payment \(resolution.transactionId) with its contact: \(error)", + context: "AppScene" + ) + } + } + private func pollIncomingPaykitPaymentRequests() async { guard scenePhase == .active else { return } @@ -842,7 +893,8 @@ struct AppScene: View { let contactPaymentContext = ContactPaymentContext( publicKey: request.counterparty, privatePaymentContext: privatePaymentContext, - incomingPaymentRequest: request + incomingPaymentRequest: request, + isInitialSubscriptionPayment: paykitPaymentRequestManager.consumeInitialSubscriptionPayment(request) ) guard app.claimContactPaymentContext(contactPaymentContext) else { return } @@ -922,6 +974,56 @@ struct AppScene: View { await presentNextIncomingPaykitPaymentRequest() } + private func handlePendingPaykitSubscriptionNotification() async { + guard let target = PaykitSubscriptionNotificationTargetStore.load() else { return } + guard let identity = pubkyProfile.publicKey else { return } + guard target.matches(identity: identity) else { + PaykitSubscriptionNotificationTargetStore.clear() + return + } + guard sheets.activeSheetConfiguration == nil, + !sheets.isReplacingSheet, + app.contactPaymentContext == nil + else { return } + await refreshIncomingPaykitPaymentRequests(presentItems: false) + guard let request = paykitPaymentRequestManager.pendingRequests.first(where: target.matches) else { + if paykitPaymentRequestManager.historyRequests.contains(where: target.matches) { + PaykitSubscriptionNotificationTargetStore.clear() + } else if paykitPaymentRequestManager.hasDismissedSubscriptionPayment(matching: target) { + PaykitSubscriptionNotificationTargetStore.clear() + } else if !paykitPaymentRequestManager.subscriptions.contains(where: { + $0.paymentRequestId == target.paymentRequestId && + PubkyPublicKeyFormat.matches($0.counterparty, target.counterparty) && + $0.counterpartyReceiverPath == target.counterpartyReceiverPath && + $0.isActive(at: Date()) + }) { + PaykitSubscriptionNotificationTargetStore.clear() + } + return + } + + if paykitPaymentRequestManager.requestedPresentationId != request.id { + guard paykitPaymentRequestManager.requestPresentation(request) else { return } + } + await presentNextIncomingPaykitPaymentRequest() + } + + private func presentNextIncomingPaykitItem() async { + guard sheets.activeSheetConfiguration == nil, !sheets.isReplacingSheet else { return } + if PaykitSubscriptionNotificationTargetStore.load() != nil { + await handlePendingPaykitSubscriptionNotification() + guard PaykitSubscriptionNotificationTargetStore.load() == nil, + sheets.activeSheetConfiguration == nil, + !sheets.isReplacingSheet + else { return } + } + if let subscription = paykitPaymentRequestManager.subscriptionProposalForPresentation() { + sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .review(subscription))) + return + } + await presentNextIncomingPaykitPaymentRequest() + } + private func retryPendingPaykitEndpointRemoval() async { if PublicPaykitService.isCleanupPending { do { diff --git a/Bitkit/Assets.xcassets/Illustrations/subscription-clock.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/subscription-clock.imageset/Contents.json new file mode 100644 index 000000000..96d30488c --- /dev/null +++ b/Bitkit/Assets.xcassets/Illustrations/subscription-clock.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "subscription-clock.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Bitkit/Assets.xcassets/Illustrations/subscription-clock.imageset/subscription-clock.png b/Bitkit/Assets.xcassets/Illustrations/subscription-clock.imageset/subscription-clock.png new file mode 100644 index 0000000000000000000000000000000000000000..2633f499bc3e0d8a9ca332602f875d83032d9bdf GIT binary patch literal 85117 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 literal 0 HcmV?d00001 diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift index be9e09e1a..dbf14402c 100644 --- a/Bitkit/BitkitApp.swift +++ b/Bitkit/BitkitApp.swift @@ -5,6 +5,70 @@ import SwiftUI /// Communication bridge between delegates and SwiftUI views extension Notification.Name { static let quickActionSelected = Notification.Name("quickActionSelected") + static let paykitSubscriptionPaymentDue = Notification.Name("paykitSubscriptionPaymentDue") +} + +struct PaykitSubscriptionNotificationTarget: Codable, Equatable { + let payerIdentity: String + let paymentRequestId: String + let counterparty: String + let counterpartyReceiverPath: String + let billingPeriodStartsAt: String + + init?(userInfo: [AnyHashable: Any]) { + guard let payerIdentity = userInfo["payer_identity"] as? String, + let paymentRequestId = userInfo["payment_request_id"] as? String, + let counterparty = userInfo["counterparty"] as? String, + let counterpartyReceiverPath = userInfo["counterparty_receiver_path"] as? String, + let billingPeriodStartsAt = userInfo["billing_period_starts_at"] as? String + else { return nil } + + self.payerIdentity = payerIdentity + self.paymentRequestId = paymentRequestId + self.counterparty = counterparty + self.counterpartyReceiverPath = counterpartyReceiverPath + self.billingPeriodStartsAt = billingPeriodStartsAt + } + + func matches(_ request: PaykitPaymentRequest) -> Bool { + paymentRequestId == request.paymentRequestId && + PubkyPublicKeyFormat.matches(counterparty, request.counterparty) && + counterpartyReceiverPath == request.counterpartyReceiverPath && + request.billingPeriod.map { + PaykitSubscriptionTimestamp.string(from: $0.startsAt) == billingPeriodStartsAt + } == true + } + + func matches(_ requestId: PaykitPaymentRequest.ID) -> Bool { + paymentRequestId == requestId.paymentRequestId && + PubkyPublicKeyFormat.matches(counterparty, requestId.counterparty) && + counterpartyReceiverPath == requestId.counterpartyReceiverPath && + requestId.billingPeriodStartsAt.map { + PaykitSubscriptionTimestamp.string(from: $0) == billingPeriodStartsAt + } == true + } + + func matches(identity: String) -> Bool { + PubkyPublicKeyFormat.matches(payerIdentity, identity) + } +} + +enum PaykitSubscriptionNotificationTargetStore { + private static let key = "paykitSubscriptionNotificationTarget" + + static func save(_ target: PaykitSubscriptionNotificationTarget) { + guard let data = try? JSONEncoder().encode(target) else { return } + UserDefaults.standard.set(data, forKey: key) + } + + static func load() -> PaykitSubscriptionNotificationTarget? { + guard let data = UserDefaults.standard.data(forKey: key) else { return nil } + return try? JSONDecoder().decode(PaykitSubscriptionNotificationTarget.self, from: data) + } + + static func clear() { + UserDefaults.standard.removeObject(forKey: key) + } } class AppDelegate: NSObject, UIApplicationDelegate { @@ -81,7 +145,14 @@ extension AppDelegate: UNUserNotificationCenterDelegate { ) { let userInfo = response.notification.request.content.userInfo - PushNotificationManager.shared.handleNotification(userInfo) + if userInfo["bitkit_action"] as? String == "paykit_subscription_due" { + if let target = PaykitSubscriptionNotificationTarget(userInfo: userInfo) { + PaykitSubscriptionNotificationTargetStore.save(target) + } + NotificationCenter.default.post(name: .paykitSubscriptionPaymentDue, object: nil, userInfo: userInfo) + } else { + PushNotificationManager.shared.handleNotification(userInfo) + } // TODO: if user tapped on an incoming tx we should open it on that tx view completionHandler() diff --git a/Bitkit/Components/Button/Button.swift b/Bitkit/Components/Button/Button.swift index 61582d0bd..61b7a06da 100644 --- a/Bitkit/Components/Button/Button.swift +++ b/Bitkit/Components/Button/Button.swift @@ -167,7 +167,8 @@ struct CustomButton: View { icon: icon, isDisabled: effectiveIsDisabled, isPressed: isPressed, - isLoading: isLoading + isLoading: isLoading, + shouldExpand: shouldExpand )) case .tertiary: AnyView(TertiaryButtonView( diff --git a/Bitkit/Components/Button/SecondaryButtonView.swift b/Bitkit/Components/Button/SecondaryButtonView.swift index c146b9bd5..7f6c8f433 100644 --- a/Bitkit/Components/Button/SecondaryButtonView.swift +++ b/Bitkit/Components/Button/SecondaryButtonView.swift @@ -7,6 +7,7 @@ struct SecondaryButtonView: View { let isDisabled: Bool let isPressed: Bool var isLoading: Bool = false + let shouldExpand: Bool var body: some View { HStack(spacing: 8) { @@ -24,8 +25,8 @@ struct SecondaryButtonView: View { BodySSBText(title, textColor: textColor) } } - .frame(maxWidth: size == .large ? .infinity : nil) - .frame(height: buttonHeight) + .frame(maxWidth: (size == .large || shouldExpand) ? .infinity : nil) + .frame(height: size.height) .padding(.horizontal, 16) .background(isPressed ? Color.white10 : Color.clear) .background(BlurView()) @@ -35,18 +36,13 @@ struct SecondaryButtonView: View { } private var textColor: Color { - isDisabled ? .white32 : .white80 + guard !isDisabled else { return .white32 } + return size == .small ? .white64 : .white80 } private var borderColor: Color { - isDisabled ? .clear : .gray4 - } - - private var buttonHeight: CGFloat { - switch size { - case .small: 37 - case .large: 56 - } + guard !isDisabled else { return .clear } + return size == .small ? .white16 : .gray4 } private var strokeWidth: CGFloat { diff --git a/Bitkit/Components/DrawerView.swift b/Bitkit/Components/DrawerView.swift index 10a8335de..f60ef761c 100644 --- a/Bitkit/Components/DrawerView.swift +++ b/Bitkit/Components/DrawerView.swift @@ -3,7 +3,7 @@ import SwiftUI enum DrawerMenuItem: Int, CaseIterable, Identifiable, Hashable { case wallet case activity - case paymentRequests + case subscriptions case contacts case profile case widgets @@ -20,7 +20,7 @@ enum DrawerMenuItem: Int, CaseIterable, Identifiable, Hashable { switch self { case .wallet: return "coins" case .activity: return "activity" - case .paymentRequests: return "file-text" + case .subscriptions: return "arrows-clockwise" case .contacts: return "users" case .profile: return "user-square" case .widgets: return "stack" @@ -35,7 +35,7 @@ enum DrawerMenuItem: Int, CaseIterable, Identifiable, Hashable { switch self { case .wallet: return t("wallet__drawer__wallet") case .activity: return t("wallet__drawer__activity") - case .paymentRequests: return t("wallet__drawer__payment_requests") + case .subscriptions: return t("subscriptions__title") case .contacts: return t("wallet__drawer__contacts") case .profile: return t("wallet__drawer__profile") case .widgets: return t("wallet__drawer__widgets") @@ -59,7 +59,7 @@ enum DrawerMenuItem: Int, CaseIterable, Identifiable, Hashable { switch self { case .wallet: return "DrawerWallet" case .activity: return "DrawerActivity" - case .paymentRequests: return "DrawerPaymentRequests" + case .subscriptions: return "DrawerSubscriptions" case .contacts: return "DrawerContacts" case .profile: return "DrawerProfile" case .widgets: return "DrawerWidgets" @@ -86,7 +86,7 @@ struct DrawerView: View { private var mainMenuItems: [DrawerMenuItem] { DrawerMenuItem.allCases.filter { item in - item.isMainMenuItem && (item != .paymentRequests || PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled) + item.isMainMenuItem && (item != .subscriptions || PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled) } } @@ -116,7 +116,7 @@ struct DrawerView: View { switch item { case .wallet: return nil case .activity: return .activityList - case .paymentRequests: return .paymentRequests + case .subscriptions: return .subscriptions(showPayments: false) case .contacts: return .contacts case .profile: return .profile case .widgets: return nil @@ -175,6 +175,8 @@ struct DrawerView: View { if showMenu { GeometryReader { geometry in + let drawerWidth = max(geometry.size.width * 0.5, min(260, geometry.size.width)) + VStack(alignment: .leading, spacing: 0) { ForEach(mainMenuItems) { item in Button(action: { @@ -199,7 +201,7 @@ struct DrawerView: View { .frame(maxWidth: .infinity) .padding(.bottom, 16) } - .frame(width: geometry.size.width * 0.5, height: geometry.size.height) + .frame(width: drawerWidth, height: geometry.size.height) .background(Color.brandAccent) .offset(x: currentDragOffset) .gesture( @@ -208,7 +210,6 @@ struct DrawerView: View { currentDragOffset = max(0, value.translation.width) } .onEnded { _ in - let drawerWidth = geometry.size.width * 0.5 let closeCompletionThreshold = drawerWidth - 100 if currentDragOffset > closeCompletionThreshold { @@ -254,9 +255,12 @@ struct DrawerView: View { .font(.custom(Fonts.black, size: 24)) .foregroundColor(.white) .kerning(-1) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 18) .dynamicTypeSize(...DynamicTypeSize.xxLarge) } + .frame(maxWidth: .infinity, alignment: .leading) .frame(height: 56) CustomDivider() diff --git a/Bitkit/Components/LabeledDetailCell.swift b/Bitkit/Components/LabeledDetailCell.swift new file mode 100644 index 000000000..15fc1fb25 --- /dev/null +++ b/Bitkit/Components/LabeledDetailCell.swift @@ -0,0 +1,26 @@ +import SwiftUI + +struct LabeledDetailCell: View { + let title: String + let value: String + let icon: String + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(title.localizedUppercase, textColor: .white64) + HStack(spacing: 4) { + Image(icon) + .resizable() + .foregroundColor(.purpleAccent) + .frame(width: 16, height: 16) + BodySSBText(value) + .lineLimit(2) + } + } + .frame(maxWidth: .infinity, minHeight: 68, alignment: .topLeading) + .padding(.bottom, 16) + .overlay(alignment: .bottom) { + Rectangle().fill(Color.white16).frame(height: 1) + } + } +} diff --git a/Bitkit/Components/SegmentedControl.swift b/Bitkit/Components/SegmentedControl.swift index 99260e1d4..2f1a1b8c0 100644 --- a/Bitkit/Components/SegmentedControl.swift +++ b/Bitkit/Components/SegmentedControl.swift @@ -3,10 +3,12 @@ import SwiftUI struct TabItem { let tab: T let activeColor: Color? + let badge: Int? - init(_ tab: T, activeColor: Color? = nil) { + init(_ tab: T, activeColor: Color? = nil, badge: Int? = nil) { self.tab = tab self.activeColor = activeColor + self.badge = badge } } @@ -37,8 +39,18 @@ struct SegmentedControl: View { } }) { VStack(spacing: 8) { - CaptionBText(tabItem.tab.description, textColor: selectedTab == tabItem.tab ? .white : .secondary) - .frame(maxWidth: .infinity) + HStack(spacing: 8) { + CaptionBText(tabItem.tab.description, textColor: selectedTab == tabItem.tab ? .white : .secondary) + + if let badge = tabItem.badge, badge > 0 { + CaptionBText("\(badge)", textColor: .black) + .frame(minWidth: 20, minHeight: 20) + .background(Color.brandAccent) + .clipShape(Circle()) + .accessibilityLabel(t("wallet__payment_requests_count", variables: ["count": "\(badge)"])) + } + } + .frame(maxWidth: .infinity) ZStack { Rectangle() .frame(height: 2) diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index cf45f67b6..e306ce0dd 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -16,6 +16,7 @@ struct MainNavView: View { @EnvironmentObject private var transfer: TransferViewModel @Environment(TrezorManager.self) private var trezorManager @Environment(HwWalletManager.self) private var hwWalletManager + @Environment(PaykitPaymentRequestManager.self) private var paykitPaymentRequestManager @Environment(\.scenePhase) var scenePhase @State private var showClipboardAlert = false @@ -130,6 +131,14 @@ struct MainNavView: View { ) { config in PaymentRequestsSheet(config: config) } + .sheet( + item: $sheets.subscriptionSheetItem, + onDismiss: { + sheets.hideSheetIfActive(.subscription, reason: "Subscription sheet dismissed") + } + ) { + config in SubscriptionSheet(config: config) + } .sheet( item: $sheets.receiveSheetItem, onDismiss: { @@ -182,6 +191,9 @@ struct MainNavView: View { .sheet( item: $sheets.sendSheetItem, onDismiss: { + if let request = app.contactPaymentContext?.incomingPaymentRequest { + Task { await paykitPaymentRequestManager.finishPayment(request) } + } app.resetSendState() wallet.resetSendState(speed: settings.defaultTransactionSpeed) sheets.hideSheetIfActive(.send, reason: "Send sheet dismissed") @@ -288,6 +300,9 @@ struct MainNavView: View { } } .onChange(of: settings.enableNotifications) { _, newValue in + Task { + await paykitPaymentRequestManager.synchronizeSubscriptionNotifications(enabled: newValue) + } // Handle notification enable/disable if newValue { // Request permission in case user was not prompted yet @@ -540,8 +555,12 @@ struct MainNavView: View { if isPaykitUIActive { EditProfileView() } else { paykitDisabledRedirectView } case .payContacts: if isPaykitUIActive { PayContactsView() } else { paykitDisabledRedirectView } - case .paymentRequests: - if isPaykitUIActive { PaymentRequestsView() } else { paykitDisabledRedirectView } + case let .subscriptions(showPayments): + if isPaykitUIActive { SubscriptionsView(showPayments: showPayments) } else { paykitDisabledRedirectView } + case let .paymentRequestDetail(id): + if isPaykitUIActive { PaymentRequestDetailView(id: id) } else { paykitDisabledRedirectView } + case let .subscriptionDetail(id): + if isPaykitUIActive { SubscriptionDetailView(id: id) } else { paykitDisabledRedirectView } // Shop case .shopIntro: ShopIntro() diff --git a/Bitkit/Managers/TagManager.swift b/Bitkit/Managers/TagManager.swift index 14bf34bf4..653eeea4f 100644 --- a/Bitkit/Managers/TagManager.swift +++ b/Bitkit/Managers/TagManager.swift @@ -8,6 +8,7 @@ final class TagManager: ObservableObject { private let userDefaultsKey = "lastUsedTags" private let maxLastUsedTags = 10 + private var preservedPaymentRequestId: PaykitPaymentRequest.ID? init() { reloadLastUsedTags() @@ -32,6 +33,16 @@ final class TagManager: ObservableObject { /// Clear all selected tags func clearSelectedTags() { selectedTags.removeAll() + preservedPaymentRequestId = nil + } + + func preserveSelectedTags(for requestId: PaykitPaymentRequest.ID) { + preservedPaymentRequestId = requestId + } + + func consumePreservedTags(for requestId: PaykitPaymentRequest.ID) -> Bool { + defer { preservedPaymentRequestId = nil } + return preservedPaymentRequestId == requestId } /// Get current selected tags as array diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index fec09aab0..e651a33df 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1214,7 +1214,6 @@ "profile__suggestions_title" = "Suggestions To Add"; "wallet__drawer__wallet" = "Wallet"; "wallet__drawer__activity" = "Activity"; -"wallet__drawer__payment_requests" = "Requests"; "wallet__drawer__contacts" = "Contacts"; "wallet__drawer__profile" = "Profile"; "wallet__drawer__widgets" = "Widgets"; @@ -1468,6 +1467,7 @@ "wallet__payment_request_mismatch" = "The payment details did not match the request. Payment cancelled."; "wallet__payment_requests" = "Payment Requests"; "wallet__payment_requests_pending_count" = "{count} pending"; +"wallet__payment_requests_count" = "{count} payment requests"; "wallet__payment_requests_review" = "Review each request, then pay or dismiss."; "wallet__payment_request_dismiss" = "Dismiss"; "wallet__payment_requests_not_now" = "Not Now"; @@ -1492,6 +1492,10 @@ "wallet__payment_request_recipient" = "Recipient"; "wallet__payment_request_enter_pubky" = "Enter pubky"; "wallet__payment_request_request_payment" = "Request Payment"; +"wallet__payment_request_or_pay" = "Request Or Pay"; +"wallet__payment_request_or_pay_headline" = "Request\nOr Pay ₿"; +"wallet__payment_request_or_pay_description" = "Pay {contact} or request a payment."; +"wallet__payment_request_request" = "Request"; "wallet__payment_request_send_request" = "Send Request"; "wallet__payment_request_sent_title" = "Sent"; "wallet__payment_request_sent_headline" = "Payment Requested"; @@ -1500,6 +1504,63 @@ "wallet__payment_request_sending" = "Sending request"; "wallet__payment_request_waiting" = "Waiting for payment"; "wallet__payment_request_waiting_for_recipient" = "Waiting for {name} to pay"; +"wallet__payment_request_note" = "Note"; +"wallet__payment_request_date" = "Date"; +"wallet__payment_request_time" = "Time"; +"wallet__payment_request_status_pending" = "Pending"; +"wallet__payment_request_status_paid" = "Paid"; +"wallet__payment_request_contact" = "Contact"; +"subscriptions__active" = "Active"; +"subscriptions__cancel" = "Cancel"; +"subscriptions__cancel_subscription" = "Cancel Subscription"; +"subscriptions__daily" = "Daily"; +"subscriptions__daily_subscription" = "Daily Subscription"; +"subscriptions__details" = "Subscription Details"; +"subscriptions__due_this_month" = "Due This Month"; +"subscriptions__empty_description" = "At the moment, you don’t have any active subscriptions from any providers."; +"subscriptions__empty_headline" = "Welcome To\nSubscriptions"; +"subscriptions__every_days" = "Every {count} days"; +"subscriptions__every_months" = "Every {count} months"; +"subscriptions__every_weeks" = "Every {count} weeks"; +"subscriptions__every_years" = "Every {count} years"; +"subscriptions__expired" = "Expired"; +"subscriptions__expires" = "Expires"; +"subscriptions__first_payment_failed" = "First Payment Failed"; +"subscriptions__first_payment_failed_description" = "You’re subscribed, but your first payment wasn’t sent."; +"subscriptions__expires_date" = "Expires {date}"; +"subscriptions__frequency" = "Frequency"; +"subscriptions__more_info" = "More Info"; +"subscriptions__monthly" = "Monthly"; +"subscriptions__monthly_subscription" = "Monthly Subscription"; +"subscriptions__ongoing" = "Ongoing"; +"subscriptions__overview" = "Overview"; +"subscriptions__payment_due_description" = "Open Bitkit to review a subscription payment."; +"subscriptions__payment_due_title" = "Subscription Payment Due"; +"subscriptions__payments" = "Payments"; +"subscriptions__per_day" = "per day"; +"subscriptions__per_month" = "per month"; +"subscriptions__per_week" = "per week"; +"subscriptions__per_year" = "per year"; +"subscriptions__proposals" = "Proposals"; +"subscriptions__renews" = "Renews"; +"subscriptions__retry_payment" = "Retry Payment"; +"subscriptions__renews_date" = "Renews {date}"; +"subscriptions__review_and_subscribe" = "Review & Subscribe"; +"subscriptions__status" = "Status"; +"subscriptions__subscribed" = "Subscribed"; +"subscriptions__subscription" = "Subscription"; +"subscriptions__swipe_to_cancel" = "Swipe To Cancel"; +"subscriptions__swipe_to_subscribe" = "Swipe To Subscribe"; +"subscriptions__swipe_to_subscribe_and_pay" = "Swipe To Subscribe & Pay"; +"subscriptions__title" = "Subscriptions"; +"subscriptions__unavailable" = "This subscription is no longer available."; +"subscriptions__unsupported_description" = "This subscription uses a payment frequency that Bitkit does not support yet."; +"subscriptions__unsupported_frequency" = "Unsupported frequency"; +"subscriptions__unsupported_payment_description" = "This subscription uses payment details that Bitkit does not support yet."; +"subscriptions__weekly" = "Weekly"; +"subscriptions__weekly_subscription" = "Weekly Subscription"; +"subscriptions__yearly" = "Yearly"; +"subscriptions__yearly_subscription" = "Yearly Subscription"; "wallet__instant_payment_received" = "Received Instant Bitcoin"; "wallet__error_create_tx" = "Transaction Creation Failed"; "wallet__error_create_tx_msg" = "An error occurred. Please try again {raw}"; diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 32a28c90d..35065d8f1 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -1,3 +1,4 @@ +import Combine import CryptoKit import Foundation import LDKNode @@ -8,13 +9,50 @@ enum PaykitPaymentProofKind: String, Codable { case onchain = "bitcoin-onchain-txid" } +struct PaykitOnchainPaymentResolution: Equatable { + let identity: String + let requestId: PaykitPaymentRequest.ID + let transactionId: String +} + struct PendingPaykitPaymentProof: Codable, Equatable { let identity: String let requestId: PaykitPaymentRequest.ID let paymentEndpointIdentifier: String let kind: PaykitPaymentProofKind + let billingPeriod: PaykitBillingPeriod? + var paymentStarted: Bool var paymentIdentifier: String? var proofData: String? + var onchainAddress: String? + var onchainAmountSats: UInt64? + var onchainMatchingTransactionIdsBeforeAttempt: Set? + + init( + identity: String, + requestId: PaykitPaymentRequest.ID, + paymentEndpointIdentifier: String, + kind: PaykitPaymentProofKind, + billingPeriod: PaykitBillingPeriod? = nil, + paymentStarted: Bool = false, + paymentIdentifier: String?, + proofData: String?, + onchainAddress: String? = nil, + onchainAmountSats: UInt64? = nil, + onchainMatchingTransactionIdsBeforeAttempt: Set? = nil + ) { + self.identity = identity + self.requestId = requestId + self.paymentEndpointIdentifier = paymentEndpointIdentifier + self.kind = kind + self.billingPeriod = billingPeriod + self.paymentStarted = paymentStarted + self.paymentIdentifier = paymentIdentifier + self.proofData = proofData + self.onchainAddress = onchainAddress + self.onchainAmountSats = onchainAmountSats + self.onchainMatchingTransactionIdsBeforeAttempt = onchainMatchingTransactionIdsBeforeAttempt + } } protocol PaykitPaymentProofStoring: Sendable { @@ -85,12 +123,60 @@ struct PaykitLightningPaymentProofLookup: PaykitLightningPaymentProofLookingUp { } } +protocol PaykitOnchainPaymentProofLookingUp: Sendable { + func existingTransactionIds(address: String, amountSats: UInt64) async throws -> Set + func transactionId(address: String, amountSats: UInt64, excluding transactionIds: Set) async throws -> String? +} + +struct PaykitOnchainPaymentProofLookup: PaykitOnchainPaymentProofLookingUp { + func existingTransactionIds(address: String, amountSats: UInt64) async throws -> Set { + try await Set(matchingTransactionIds(address: address, amountSats: amountSats).map { $0.lowercased() }) + } + + func transactionId(address: String, amountSats: UInt64, excluding transactionIds: Set) async throws -> String? { + try await matchingTransactionIds(address: address, amountSats: amountSats) + .reversed() + .first { !transactionIds.contains($0.lowercased()) } + } + + private func matchingTransactionIds(address: String, amountSats: UInt64) async throws -> [String] { + guard let payments = await LightningService.shared.listPayments() else { + throw PaykitPaymentRequestError.requestUnavailable + } + var transactionIds: [String] = [] + for payment in payments { + guard payment.direction == .outbound, + payment.status != .failed, + case let .onchain(txid, _) = payment.kind, + let details = try? await CoreService.shared.activity.getTransactionDetails(txid: txid), + details.outputs.contains(where: { + $0.scriptpubkeyAddress == address && $0.value == amountSats + }) + else { continue } + transactionIds.append(txid) + } + return transactionIds + } +} + actor PaykitPaymentProofService { static let shared = PaykitPaymentProofService() + private static let proofStateChangedSubject = PassthroughSubject() + private static let onchainPaymentResolutionSubject = CurrentValueSubject(nil) + + nonisolated static var proofStateChangedPublisher: AnyPublisher { + proofStateChangedSubject.eraseToAnyPublisher() + } + + nonisolated static var onchainPaymentResolutionPublisher: AnyPublisher { + onchainPaymentResolutionSubject.compactMap { $0 }.eraseToAnyPublisher() + } + private let sdk: any PaykitPaymentProofSdkHandling private let store: any PaykitPaymentProofStoring private let lightningPaymentLookup: any PaykitLightningPaymentProofLookingUp + private let onchainPaymentLookup: any PaykitOnchainPaymentProofLookingUp private let logInfo: @Sendable (String) -> Void private let logWarning: @Sendable (String) -> Void @@ -98,6 +184,7 @@ actor PaykitPaymentProofService { sdk: any PaykitPaymentProofSdkHandling = PaykitSdkService.shared, store: any PaykitPaymentProofStoring = PaykitPaymentProofStore(), lightningPaymentLookup: any PaykitLightningPaymentProofLookingUp = PaykitLightningPaymentProofLookup(), + onchainPaymentLookup: any PaykitOnchainPaymentProofLookingUp = PaykitOnchainPaymentProofLookup(), logInfo: @escaping @Sendable (String) -> Void = { Logger.info($0, context: "PaykitPaymentProof") }, @@ -108,6 +195,7 @@ actor PaykitPaymentProofService { self.sdk = sdk self.store = store self.lightningPaymentLookup = lightningPaymentLookup + self.onchainPaymentLookup = onchainPaymentLookup self.logInfo = logInfo self.logWarning = logWarning } @@ -124,9 +212,17 @@ actor PaykitPaymentProofService { ) var pendingProofs = try await loadProofs() + guard !pendingProofs.contains(where: { + PubkyPublicKeyFormat.matches($0.identity, proof.identity) && + $0.requestId == request.id && + ($0.paymentStarted || $0.paymentIdentifier != nil || $0.proofData != nil) + }) else { + throw PaykitPaymentRequestError.operationInProgress + } pendingProofs.removeAll { PubkyPublicKeyFormat.matches($0.identity, proof.identity) && $0.requestId == request.id && + !$0.paymentStarted && $0.paymentIdentifier == nil && $0.proofData == nil } @@ -154,6 +250,7 @@ actor PaykitPaymentProofService { requestId: request.id, paymentEndpointIdentifier: paymentEndpointIdentifier, kind: kind, + billingPeriod: request.billingPeriod, paymentIdentifier: nil, proofData: nil ) @@ -164,17 +261,47 @@ actor PaykitPaymentProofService { throw PaykitPaymentRequestError.requestUnavailable } + let identity = try await currentIdentity() var pendingProofs = try await loadProofs() guard let index = pendingProofs.lastIndex(where: { - $0.requestId == request.id && + PubkyPublicKeyFormat.matches($0.identity, identity) && + $0.requestId == request.id && $0.kind == .lightning && + !$0.paymentStarted && $0.paymentIdentifier == nil && $0.proofData == nil }) else { throw PaykitPaymentRequestError.requestUnavailable } + pendingProofs[index].paymentStarted = true pendingProofs[index].paymentIdentifier = paymentHash.lowercased() try await persist(pendingProofs) + Self.proofStateChangedSubject.send() + } + + func markOnchainPaymentStarted(_ request: PaykitPaymentRequest, address: String) async throws { + let identity = try await currentIdentity() + let existingTransactionIds = try await onchainPaymentLookup.existingTransactionIds( + address: address, + amountSats: request.amountSats + ) + var pendingProofs = try await loadProofs() + guard let index = pendingProofs.lastIndex(where: { + PubkyPublicKeyFormat.matches($0.identity, identity) && + $0.requestId == request.id && + $0.kind == .onchain && + !$0.paymentStarted && + $0.paymentIdentifier == nil && + $0.proofData == nil + }) else { + throw PaykitPaymentRequestError.requestUnavailable + } + pendingProofs[index].paymentStarted = true + pendingProofs[index].onchainAddress = address + pendingProofs[index].onchainAmountSats = request.amountSats + pendingProofs[index].onchainMatchingTransactionIdsBeforeAttempt = existingTransactionIds + try await persist(pendingProofs) + Self.proofStateChangedSubject.send() } func completeLightningPayment(paymentHash: String, preimage: String?) async { @@ -208,6 +335,26 @@ actor PaykitPaymentProofService { _ request: PaykitPaymentRequest, txid: String, paymentEndpointIdentifier: String + ) async { + guard let identity = try? await currentIdentity() else { return } + let fallbackProof = try? await pendingProof( + request: request, + paymentEndpointIdentifier: paymentEndpointIdentifier, + kind: .onchain + ) + await completeOnchainPayment( + requestId: request.id, + identity: identity, + txid: txid, + fallbackProof: fallbackProof + ) + } + + private func completeOnchainPayment( + requestId: PaykitPaymentRequest.ID, + identity: String, + txid: String, + fallbackProof: PendingPaykitPaymentProof? = nil ) async { guard Self.isHex(txid, byteCount: 32) else { logWarning("Ignored a Paykit on-chain proof with an invalid transaction id") @@ -217,28 +364,33 @@ actor PaykitPaymentProofService { do { var pendingProofs = try await loadProofs() guard let index = pendingProofs.lastIndex(where: { - $0.requestId == request.id && + PubkyPublicKeyFormat.matches($0.identity, identity) && + $0.requestId == requestId && $0.kind == .onchain && + $0.paymentStarted && $0.paymentIdentifier == nil && $0.proofData == nil }) else { return } pendingProofs[index].paymentIdentifier = txid.lowercased() pendingProofs[index].proofData = txid.lowercased() await persistAndSubmit([pendingProofs[index]], allProofs: pendingProofs) + Self.onchainPaymentResolutionSubject.send(PaykitOnchainPaymentResolution( + identity: pendingProofs[index].identity, + requestId: requestId, + transactionId: txid.lowercased() + )) } catch { logWarning("Failed to load a Paykit on-chain payment proof; attempting immediate delivery: \(error)") - do { - var proof = try await pendingProof( - request: request, - paymentEndpointIdentifier: paymentEndpointIdentifier, - kind: .onchain - ) - proof.paymentIdentifier = txid.lowercased() - proof.proofData = txid.lowercased() - await submit(proof) - } catch { - logWarning("Failed to complete a Paykit on-chain payment proof: \(error)") - } + guard var fallbackProof else { return } + fallbackProof.paymentStarted = true + fallbackProof.paymentIdentifier = txid.lowercased() + fallbackProof.proofData = txid.lowercased() + await submit(fallbackProof) + Self.onchainPaymentResolutionSubject.send(PaykitOnchainPaymentResolution( + identity: fallbackProof.identity, + requestId: requestId, + transactionId: txid.lowercased() + )) } } @@ -248,9 +400,24 @@ actor PaykitPaymentProofService { } } + func failOnchainPayment(_ request: PaykitPaymentRequest) async { + guard let identity = try? await currentIdentity() else { return } + await removeProofs { + PubkyPublicKeyFormat.matches($0.identity, identity) && + $0.requestId == request.id && + $0.kind == .onchain && + $0.paymentStarted && + $0.paymentIdentifier == nil && + $0.proofData == nil + } + } + func cancelPreparation(_ request: PaykitPaymentRequest) async { + guard let identity = try? await currentIdentity() else { return } await removeProofs { - $0.requestId == request.id && + PubkyPublicKeyFormat.matches($0.identity, identity) && + $0.requestId == request.id && + !$0.paymentStarted && $0.paymentIdentifier == nil && $0.proofData == nil } @@ -272,6 +439,19 @@ actor PaykitPaymentProofService { await submit(proof) continue } + if proof.kind == .onchain, + proof.paymentStarted, + let address = proof.onchainAddress, + let amountSats = proof.onchainAmountSats, + let txid = try await onchainPaymentLookup.transactionId( + address: address, + amountSats: amountSats, + excluding: proof.onchainMatchingTransactionIdsBeforeAttempt ?? [] + ) + { + await completeOnchainPayment(requestId: proof.requestId, identity: proof.identity, txid: txid) + continue + } guard proof.kind == PaykitPaymentProofKind.lightning, let paymentHash = proof.paymentIdentifier else { continue } switch await lightningPaymentLookup.status(paymentHash: paymentHash) { case .pending, .unknown: @@ -287,24 +467,89 @@ actor PaykitPaymentProofService { } } - private func submit(_ pendingProof: PendingPaykitPaymentProof) async { - guard let proofData = pendingProof.proofData else { return } + func completedRequestIdsAwaitingSubmission(identity: String) async -> Set { + do { + return try await Set(loadProofs().compactMap { proof in + guard PubkyPublicKeyFormat.matches(proof.identity, identity), proof.proofData != nil else { return nil } + return proof.requestId + }) + } catch { + logWarning("Failed to inspect pending Paykit payment proofs: \(error)") + return [] + } + } + + func inFlightRequestIds(identity: String) async -> Set { + do { + return try await Set(loadProofs().compactMap { proof in + guard PubkyPublicKeyFormat.matches(proof.identity, identity), proof.paymentStarted else { return nil } + return proof.requestId + }) + } catch { + logWarning("Failed to inspect in-flight Paykit payment proofs: \(error)") + return [] + } + } + + func protectedRequestIdsForSubscriptionCancellation( + identity: String, + subscriptionId: PaykitSubscription.ID + ) async throws -> Set { + let proofs = try await loadProofs() + let belongsToSubscription: (PendingPaykitPaymentProof) -> Bool = { + PubkyPublicKeyFormat.matches($0.identity, identity) && + $0.requestId.billingPeriodStartsAt != nil && + $0.requestId.paymentRequestId == subscriptionId.paymentRequestId && + $0.requestId.counterparty == subscriptionId.counterparty && + $0.requestId.counterpartyReceiverPath == subscriptionId.counterpartyReceiverPath + } + let protectedRequestIds: Set = Set(proofs.compactMap { proof in + guard belongsToSubscription(proof) else { return nil } + guard proof.paymentStarted || proof.paymentIdentifier != nil || proof.proofData != nil else { return nil } + return proof.requestId + }) + let remainingProofs = proofs.filter { + !belongsToSubscription($0) || $0.paymentStarted || $0.paymentIdentifier != nil || $0.proofData != nil + } + if remainingProofs != proofs { + try await persist(remainingProofs) + Self.proofStateChangedSubject.send() + } + return protectedRequestIds + } + + func consumeOnchainPaymentResolution(_ resolution: PaykitOnchainPaymentResolution) { + guard Self.onchainPaymentResolutionSubject.value == resolution else { return } + Self.onchainPaymentResolutionSubject.send(nil) + } + + private func currentIdentity() async throws -> String { + guard let identityStatus = try await sdk.identityStatus(), + let publicKey = identityStatus.publicKey, + let identity = PubkyPublicKeyFormat.normalized(publicKey) + else { throw PaykitPaymentRequestError.requestUnavailable } + return identity + } + + @discardableResult + private func submit(_ pendingProof: PendingPaykitPaymentProof) async -> Bool { + guard let proofData = pendingProof.proofData else { return false } do { guard let identityStatus = try await sdk.identityStatus(), identityStatus.liveSessionAvailable, PubkyPublicKeyFormat.matches(identityStatus.publicKey, pendingProof.identity) - else { return } + else { return false } let records = try await sdk.paymentRequests() guard let request = records.first(where: { $0.paymentRequestId == pendingProof.requestId.paymentRequestId && PubkyPublicKeyFormat.matches($0.counterparty, pendingProof.requestId.counterparty) && $0.counterpartyReceiverPath == pendingProof.requestId.counterpartyReceiverPath - }) else { return } + }) else { return false } let proofText = try Self.proofText(kind: pendingProof.kind, data: proofData) let isAlreadyQueued = request.paymentProofs.contains(where: { - $0.billingPeriod == nil && + Self.billingPeriod($0.billingPeriod, matches: pendingProof.billingPeriod) && $0.paymentEndpointIdentifier == pendingProof.paymentEndpointIdentifier && Self.proofValues($0.proof.exportText()) == Self.proofValues(proofText) }) @@ -315,7 +560,7 @@ actor PaykitPaymentProofService { counterpartyReceiverPath: pendingProof.requestId.counterpartyReceiverPath, paymentRequestId: pendingProof.requestId.paymentRequestId, proof: Paykit.PaymentProofSubmission( - billingPeriod: nil, + billingPeriod: pendingProof.billingPeriod?.sdkValue, paymentEndpointIdentifier: pendingProof.paymentEndpointIdentifier, proof: Paykit.PrivateJsonObject(text: proofText) ) @@ -328,8 +573,10 @@ actor PaykitPaymentProofService { } } await removeRequestProofs(pendingProof) + return true } catch { logWarning("Failed to queue a Paykit payment proof: \(error)") + return false } } @@ -345,13 +592,29 @@ actor PaykitPaymentProofService { _ completedProofs: [PendingPaykitPaymentProof], allProofs: [PendingPaykitPaymentProof] ) async { + let didPersist: Bool do { try await persist(allProofs) + didPersist = true } catch { + didPersist = false logWarning("Failed to persist a completed Paykit payment proof; attempting immediate delivery: \(error)") } + var hasUndeliveredProof = false for proof in completedProofs { - await submit(proof) + if await !submit(proof) { + hasUndeliveredProof = true + } + } + if !didPersist, hasUndeliveredProof { + do { + try await persist(allProofs) + } catch { + logWarning("Failed to retain a completed Paykit payment proof for retry: \(error)") + } + } + if !completedProofs.isEmpty { + Self.proofStateChangedSubject.send() } } @@ -367,6 +630,7 @@ actor PaykitPaymentProofService { let remainingProofs = pendingProofs.filter { !shouldRemove($0) } guard remainingProofs != pendingProofs else { return } try await persist(remainingProofs) + Self.proofStateChangedSubject.send() } catch { logWarning("Failed to clear a pending Paykit payment proof: \(error)") } @@ -419,4 +683,15 @@ actor PaykitPaymentProofService { else { return nil } return values } + + private static func billingPeriod(_ sdkPeriod: Paykit.BillingPeriod?, matches period: PaykitBillingPeriod?) -> Bool { + switch (sdkPeriod.flatMap(PaykitBillingPeriod.init), period) { + case (nil, nil): + true + case let (sdkPeriod?, period?): + sdkPeriod == period + default: + false + } + } } diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index c492a16b0..5a5d38b8a 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -16,6 +16,19 @@ struct PaykitPaymentRequest: Identifiable, Hashable { let paymentRequestId: String let counterparty: String let counterpartyReceiverPath: String + let billingPeriodStartsAt: Date? + + init( + paymentRequestId: String, + counterparty: String, + counterpartyReceiverPath: String, + billingPeriodStartsAt: Date? = nil + ) { + self.paymentRequestId = paymentRequestId + self.counterparty = counterparty + self.counterpartyReceiverPath = counterpartyReceiverPath + self.billingPeriodStartsAt = billingPeriodStartsAt + } } let paymentRequestId: String @@ -30,12 +43,18 @@ struct PaykitPaymentRequest: Identifiable, Hashable { let deliveryStatus: DeliveryStatus? let direction: Direction let lifecycleState: Paykit.PaymentRequestLifecycleState + let billingPeriod: PaykitBillingPeriod? + + var requiresAcceptance: Bool { + billingPeriod == nil && lifecycleState == .proposed + } var id: ID { ID( paymentRequestId: paymentRequestId, counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath + counterpartyReceiverPath: counterpartyReceiverPath, + billingPeriodStartsAt: billingPeriod?.startsAt ) } @@ -68,7 +87,7 @@ struct PaykitPaymentRequest: Identifiable, Hashable { amountSats <= UInt64.max / 1000 else { return nil } - if requiresActionableRequest, record.state != .proposed { + if requiresActionableRequest, record.state != .proposed, record.state != .accepted { return nil } @@ -82,7 +101,7 @@ struct PaykitPaymentRequest: Identifiable, Hashable { let expiresAt: Date? if let proposalExpiresAt = terms.proposalExpiresAt { guard let parsedExpiration = Self.parseDate(proposalExpiresAt), - !requiresActionableRequest || parsedExpiration > now + !requiresActionableRequest || record.state != .proposed || parsedExpiration > now else { return nil } @@ -103,6 +122,7 @@ struct PaykitPaymentRequest: Identifiable, Hashable { deliveryStatus = expectedRole == .payee ? Self.deliveryStatus(from: record.proposalOutboundStatus) : nil direction = expectedRole == .payer ? .incoming : .outgoing lifecycleState = record.state + billingPeriod = nil } init( @@ -126,6 +146,7 @@ struct PaykitPaymentRequest: Identifiable, Hashable { self.deliveryStatus = deliveryStatus direction = .outgoing lifecycleState = .proposed + billingPeriod = nil } func updatingLifecycleState(_ state: Paykit.PaymentRequestLifecycleState) -> PaykitPaymentRequest { @@ -141,10 +162,27 @@ struct PaykitPaymentRequest: Identifiable, Hashable { acceptedPaymentEndpointIdentifiers: acceptedPaymentEndpointIdentifiers, deliveryStatus: deliveryStatus, direction: direction, - lifecycleState: state + lifecycleState: state, + billingPeriod: billingPeriod ) } + init(subscription: PaykitSubscription, billingPeriod: PaykitBillingPeriod, lifecycleState: Paykit.PaymentRequestLifecycleState) { + paymentRequestId = subscription.paymentRequestId + counterparty = subscription.counterparty + counterpartyReceiverPath = subscription.counterpartyReceiverPath + amountValue = subscription.amountValue + amountSats = subscription.amountSats + note = subscription.note + createdAt = billingPeriod.startsAt + expiresAt = nil + acceptedPaymentEndpointIdentifiers = subscription.acceptedPaymentEndpointIdentifiers + deliveryStatus = nil + direction = .incoming + self.lifecycleState = lifecycleState + self.billingPeriod = billingPeriod + } + private init( paymentRequestId: String, counterparty: String, @@ -157,7 +195,8 @@ struct PaykitPaymentRequest: Identifiable, Hashable { acceptedPaymentEndpointIdentifiers: [String], deliveryStatus: DeliveryStatus?, direction: Direction, - lifecycleState: Paykit.PaymentRequestLifecycleState + lifecycleState: Paykit.PaymentRequestLifecycleState, + billingPeriod: PaykitBillingPeriod? ) { self.paymentRequestId = paymentRequestId self.counterparty = counterparty @@ -171,10 +210,11 @@ struct PaykitPaymentRequest: Identifiable, Hashable { self.deliveryStatus = deliveryStatus self.direction = direction self.lifecycleState = lifecycleState + self.billingPeriod = billingPeriod } func isExpired(at date: Date) -> Bool { - expiresAt.map { $0 <= date } ?? false + lifecycleState == .proposed && (expiresAt.map { $0 <= date } ?? false) } func acceptsLightningInvoiceAmount(milliSatoshis: UInt64?) -> Bool { @@ -187,7 +227,14 @@ struct PaykitPaymentRequest: Identifiable, Hashable { amountSats == self.amountSats } - private static func supportedEndpointIdentifiers(_ identifiers: [String]) -> [String] { + func belongs(to subscription: PaykitSubscription) -> Bool { + billingPeriod != nil && + paymentRequestId == subscription.paymentRequestId && + counterparty == subscription.counterparty && + counterpartyReceiverPath == subscription.counterpartyReceiverPath + } + + static func supportedEndpointIdentifiers(_ identifiers: [String]) -> [String] { var seen = Set() return identifiers.filter { identifier in guard seen.insert(identifier).inserted, @@ -202,7 +249,7 @@ struct PaykitPaymentRequest: Identifiable, Hashable { } } - private static func sats(fromBitcoinAmount amount: String) -> UInt64? { + static func sats(fromBitcoinAmount amount: String) -> UInt64? { let components = amount.split(separator: ".", maxSplits: 1, omittingEmptySubsequences: false) let digits = components.joined() guard digits.utf8.allSatisfy({ $0 >= 48 && $0 <= 57 }), @@ -225,7 +272,7 @@ struct PaykitPaymentRequest: Identifiable, Hashable { return amountSats } - private static func parseDate(_ timestamp: String) -> Date? { + static func parseDate(_ timestamp: String) -> Date? { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let date = formatter.date(from: timestamp) { @@ -235,7 +282,7 @@ struct PaykitPaymentRequest: Identifiable, Hashable { return formatter.date(from: timestamp) } - private static func note(from metadata: Paykit.PrivateJsonObject) -> String? { + static func note(from metadata: Paykit.PrivateJsonObject) -> String? { guard let data = metadata.exportText().data(using: .utf8), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let note = object["note"] as? String @@ -271,6 +318,17 @@ struct PaykitPaymentRequestDraft: Hashable { struct PaykitPaymentRequestSnapshot: Equatable { let incoming: [PaykitPaymentRequest] let history: [PaykitPaymentRequest] + let subscriptions: [PaykitSubscription] + + init( + incoming: [PaykitPaymentRequest], + history: [PaykitPaymentRequest], + subscriptions: [PaykitSubscription] = [] + ) { + self.incoming = incoming + self.history = history + self.subscriptions = subscriptions + } } enum PaykitPaymentRequestError: LocalizedError, Equatable { @@ -317,6 +375,12 @@ protocol PaykitPaymentRequestSdkHandling: Sendable { paymentRequestId: String, reason: String? ) async throws -> Paykit.PaymentRequestRecord + func cancelPaymentRequest( + counterparty: String, + counterpartyReceiverPath: String, + paymentRequestId: String, + reason: String? + ) async throws -> Paykit.PaymentRequestRecord } extension PaykitSdkService: PaykitPaymentRequestSdkHandling {} @@ -355,7 +419,12 @@ struct PaykitPaymentRequestService { let history = records.compactMap { PaykitPaymentRequest(historyRecord: $0, now: synchronizationDate) } - return PaykitPaymentRequestSnapshot(incoming: incoming, history: history) + let subscriptions = records.compactMap(PaykitSubscription.init) + return PaykitPaymentRequestSnapshot( + incoming: incoming, + history: history, + subscriptions: subscriptions + ) } func eligibleTargets(savedPublicKeys: [String], expectedIdentity: String) async throws -> [PaykitPaymentRequestTarget] { @@ -471,6 +540,51 @@ struct PaykitPaymentRequestService { _ = try? await processPendingMessages() } + func cancel(_ request: PaykitPaymentRequest) async throws { + _ = try await sdk.cancelPaymentRequest( + counterparty: request.counterparty, + counterpartyReceiverPath: request.counterpartyReceiverPath, + paymentRequestId: request.paymentRequestId, + reason: nil + ) + _ = try? await processPendingMessages() + } + + func accept(_ subscription: PaykitSubscription) async throws -> PaykitSubscription { + guard subscription.isProposalActionable(at: now()) else { + throw PaykitPaymentRequestError.requestExpired + } + + let record = try await sdk.acceptPaymentRequest( + counterparty: subscription.counterparty, + counterpartyReceiverPath: subscription.counterpartyReceiverPath, + paymentRequestId: subscription.paymentRequestId + ) + _ = try? await processPendingMessages() + guard let subscription = PaykitSubscription(record: record) else { + throw PaykitPaymentRequestError.requestUnavailable + } + return subscription + } + + func cancel(_ subscription: PaykitSubscription) async throws -> PaykitSubscription { + guard subscription.isActive(at: now()) else { + throw PaykitPaymentRequestError.requestUnavailable + } + + let record = try await sdk.cancelPaymentRequest( + counterparty: subscription.counterparty, + counterpartyReceiverPath: subscription.counterpartyReceiverPath, + paymentRequestId: subscription.paymentRequestId, + reason: nil + ) + _ = try? await processPendingMessages() + guard let subscription = PaykitSubscription(record: record) else { + throw PaykitPaymentRequestError.requestUnavailable + } + return subscription + } + private static func acceptedPaymentEndpointIdentifiers() -> [String] { PublicPaykitService.MethodId.publishableMethodIds.compactMap { methodId in if methodId == .bitcoinLightningBolt11 { @@ -574,18 +688,30 @@ final class PaykitPaymentRequestManager { private(set) var pendingRequests: [PaykitPaymentRequest] = [] private(set) var historyRequests: [PaykitPaymentRequest] = [] + private(set) var subscriptions: [PaykitSubscription] = [] private(set) var eligibleTargets: [PaykitPaymentRequestTarget] = [] private(set) var requestedPresentationId: PaykitPaymentRequest.ID? + private(set) var requestedSubscriptionProposalId: PaykitSubscription.ID? private(set) var isCreatingRequest = false + private(set) var isProcessingSubscription = false private(set) var presentationRetryTrigger = 0 private let service: PaykitPaymentRequestService private let presentationStore: any PaykitPaymentRequestPresentationStoring + private let subscriptionStateStore: any PaykitSubscriptionStateStoring + private let subscriptionNotificationScheduler: PaykitSubscriptionNotificationScheduler + private let completedPaymentRequestIds: @Sendable (String) async -> Set + private let inFlightPaymentRequestIds: @Sendable (String) async -> Set + private let protectedRequestIdsForSubscriptionCancellation: @Sendable ( + String, + PaykitSubscription.ID + ) async throws -> Set private let now: @Sendable () -> Date private let logWarning: @Sendable (String) -> Void private let isAvailable: @MainActor () -> Bool private var processingRequestIds: Set = [] private var approvedPaymentRequestIds: Set = [] + private var initialSubscriptionPaymentRequestIds: Set = [] private var presentedRequestIds: Set = [] private var presentationRetryAttempts: [PaykitPaymentRequest.ID: Int] = [:] private var presentationRetryDates: [PaykitPaymentRequest.ID: Date] = [:] @@ -601,14 +727,43 @@ final class PaykitPaymentRequestManager { private var activeIdentity: String? private var savedPublicKeys: [String] = [] private var persistedPresentedRequestIds: Set = [] + private var subscriptionAcceptedAt: [PaykitSubscription.ID: Date] = [:] + private var presentedSubscriptionProposalIds: Set = [] + private var dismissedSubscriptionPaymentIds: Set = [] + private var persistedSubscriptionState = PaykitSubscriptionState() var outgoingRequests: [PaykitPaymentRequest] { historyRequests.filter { $0.direction == .outgoing } } + func acceptedAt(for subscription: PaykitSubscription) -> Date? { + subscriptionAcceptedAt[subscription.id] + } + + func hasDismissedSubscriptionPayment(matching target: PaykitSubscriptionNotificationTarget) -> Bool { + dismissedSubscriptionPaymentIds.contains(where: target.matches) + } + init( service: PaykitPaymentRequestService? = nil, presentationStore: any PaykitPaymentRequestPresentationStoring = PaykitPaymentRequestPresentationStore(), + subscriptionStateStore: any PaykitSubscriptionStateStoring = PaykitSubscriptionStateStore(), + subscriptionNotificationScheduler: PaykitSubscriptionNotificationScheduler = PaykitSubscriptionNotificationScheduler(), + completedPaymentRequestIds: @escaping @Sendable (String) async -> Set = { identity in + await PaykitPaymentProofService.shared.completedRequestIdsAwaitingSubmission(identity: identity) + }, + inFlightPaymentRequestIds: @escaping @Sendable (String) async -> Set = { identity in + await PaykitPaymentProofService.shared.inFlightRequestIds(identity: identity) + }, + protectedRequestIdsForSubscriptionCancellation: @escaping @Sendable ( + String, + PaykitSubscription.ID + ) async throws -> Set = { identity, subscriptionId in + try await PaykitPaymentProofService.shared.protectedRequestIdsForSubscriptionCancellation( + identity: identity, + subscriptionId: subscriptionId + ) + }, now: @escaping @Sendable () -> Date = { Date() }, isAvailable: @escaping @MainActor () -> Bool = { PaykitFeatureFlags.isUIEnabled }, logWarning: @escaping @Sendable (String) -> Void = { @@ -617,6 +772,11 @@ final class PaykitPaymentRequestManager { ) { self.service = service ?? PaykitPaymentRequestService(now: now, logWarning: logWarning) self.presentationStore = presentationStore + self.subscriptionStateStore = subscriptionStateStore + self.subscriptionNotificationScheduler = subscriptionNotificationScheduler + self.completedPaymentRequestIds = completedPaymentRequestIds + self.inFlightPaymentRequestIds = inFlightPaymentRequestIds + self.protectedRequestIdsForSubscriptionCancellation = protectedRequestIdsForSubscriptionCancellation self.now = now self.isAvailable = isAvailable self.logWarning = logWarning @@ -637,6 +797,19 @@ final class PaykitPaymentRequestManager { persistedPresentedRequestIds = [] logWarning("Failed to restore surfaced Paykit payment requests: \(error)") } + do { + let subscriptionState = try subscriptionStateStore.load(identity: normalizedIdentity) + subscriptionAcceptedAt = subscriptionState.acceptedAt + presentedSubscriptionProposalIds = subscriptionState.presentedProposalIds + dismissedSubscriptionPaymentIds = subscriptionState.dismissedPaymentIds + persistedSubscriptionState = subscriptionState + } catch { + subscriptionAcceptedAt = [:] + presentedSubscriptionProposalIds = [] + dismissedSubscriptionPaymentIds = [] + persistedSubscriptionState = PaykitSubscriptionState() + logWarning("Failed to restore Paykit subscription state: \(error)") + } } func refreshEligibleTargets(savedPublicKeys: [String]) async { @@ -718,6 +891,18 @@ final class PaykitPaymentRequestManager { await refresh(excludingProtectedRequestId: nil) } + func synchronizeSubscriptionNotifications(enabled: Bool) async { + guard let activeIdentity else { return } + await subscriptionNotificationScheduler.synchronize( + subscriptions, + acceptedAt: subscriptionAcceptedAt, + pendingRequestIds: Set(pendingRequests.map(\.id)), + payerIdentity: activeIdentity, + notificationsEnabled: enabled, + now: now() + ) + } + private func refresh(excludingProtectedRequestId: PaykitPaymentRequest.ID?) async { if let refreshTask { await refreshTask.value @@ -742,9 +927,16 @@ final class PaykitPaymentRequestManager { consumePrivatePaymentList: () async throws -> Void = {} ) async throws { do { - try await perform(request, resultingState: .accepted, markApprovedForPayment: true) { + try await perform( + request, + resultingState: .accepted, + markApprovedForPayment: true, + preservePending: !request.requiresAcceptance + ) { try await consumePrivatePaymentList() - try await service.accept($0) + if $0.requiresAcceptance { + try await service.accept($0) + } } } catch is CancellationError { throw CancellationError() @@ -763,8 +955,153 @@ final class PaykitPaymentRequestManager { } } + func dismiss(_ request: PaykitPaymentRequest) async throws { + if request.billingPeriod != nil { + guard dismissSubscriptionPayment(request) else { + throw PaykitPaymentRequestError.requestUnavailable + } + await synchronizeSubscriptionNotifications(enabled: SettingsViewModel.shared.enableNotifications) + clearNotificationTarget(matching: request) + return + } + + if request.requiresAcceptance { + try await reject(request) + return + } + + guard request.lifecycleState == .accepted else { + throw PaykitPaymentRequestError.requestUnavailable + } + try await perform(request, resultingState: .canceled) { + try await service.cancel($0) + } + clearNotificationTarget(matching: request) + } + + private func clearNotificationTarget(matching request: PaykitPaymentRequest) { + if PaykitSubscriptionNotificationTargetStore.load()?.matches(request) == true { + PaykitSubscriptionNotificationTargetStore.clear() + } + } + + func requestSubscriptionPresentation(_ subscription: PaykitSubscription) { + guard subscriptions.contains(where: { $0.id == subscription.id }), + subscription.isProposalVisible(at: now()), + !isProcessingSubscription + else { return } + requestedSubscriptionProposalId = subscription.id + } + + func subscriptionProposalForPresentation() -> PaykitSubscription? { + if let requestedSubscriptionProposalId { + return subscriptions.first { + $0.id == requestedSubscriptionProposalId && $0.isProposalVisible(at: now()) + } + } + return subscriptions.first { + $0.isProposalVisible(at: now()) && !presentedSubscriptionProposalIds.contains($0.id) + } + } + + func markSubscriptionProposalPresented(_ subscription: PaykitSubscription) { + presentedSubscriptionProposalIds.insert(subscription.id) + if requestedSubscriptionProposalId == subscription.id { + requestedSubscriptionProposalId = nil + } + persistSubscriptionState() + } + @discardableResult - func requestPresentation(_ request: PaykitPaymentRequest) -> Bool { + func dismissSubscriptionPayment(_ request: PaykitPaymentRequest) -> Bool { + guard request.billingPeriod != nil, + pendingRequests.contains(where: { $0.id == request.id }) + else { return false } + + dismissedSubscriptionPaymentIds.insert(request.id) + pendingRequests.removeAll { $0.id == request.id } + presentedRequestIds.remove(request.id) + presentationRetryAttempts.removeValue(forKey: request.id) + presentationRetryDates.removeValue(forKey: request.id) + if requestedPresentationId == request.id { + presentationGeneration += 1 + requestedPresentationId = nil + } + persistSubscriptionState() + persistPresentedRequestIds() + schedulePresentationRetry() + return true + } + + @discardableResult + func accept(_ subscription: PaykitSubscription) async throws -> PaykitPaymentRequest? { + guard !isProcessingSubscription else { throw PaykitPaymentRequestError.operationInProgress } + guard let current = subscriptions.first(where: { $0.id == subscription.id }), + current == subscription, + current.isProposalActionable(at: now()), + let activeIdentity + else { throw PaykitPaymentRequestError.requestUnavailable } + + let actionGeneration = stateGeneration + isProcessingSubscription = true + defer { + if actionGeneration == stateGeneration { + isProcessingSubscription = false + } + } + let acceptedSubscription = try await service.accept(current) + let acceptanceDate = now() + guard actionGeneration == stateGeneration, + PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity) + else { return nil } + subscriptionAcceptedAt[current.id] = acceptanceDate + presentedSubscriptionProposalIds.insert(current.id) + requestedSubscriptionProposalId = nil + persistSubscriptionState(identity: activeIdentity) + await applyCommittedSubscription(acceptedSubscription, at: acceptanceDate) + invalidateRefresh() + await refresh() + return pendingRequests + .filter { $0.belongs(to: current) } + .min { ($0.billingPeriod?.startsAt ?? .distantFuture) < ($1.billingPeriod?.startsAt ?? .distantFuture) } + } + + func cancel(_ subscription: PaykitSubscription) async throws { + guard !isProcessingSubscription else { throw PaykitPaymentRequestError.operationInProgress } + guard let current = subscriptions.first(where: { $0.id == subscription.id }), + current.isActive(at: now()), + let activeIdentity + else { + throw PaykitPaymentRequestError.requestUnavailable + } + + let actionGeneration = stateGeneration + isProcessingSubscription = true + defer { + if actionGeneration == stateGeneration { + isProcessingSubscription = false + } + } + + let protectedRequestIds = try await protectedRequestIdsForSubscriptionCancellation(activeIdentity, current.id) + guard actionGeneration == stateGeneration, + PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity) + else { return } + guard protectedRequestIds.isEmpty else { + throw PaykitPaymentRequestError.operationInProgress + } + + let canceledSubscription = try await service.cancel(current) + guard actionGeneration == stateGeneration, + PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity) + else { return } + await applyCommittedSubscription(canceledSubscription, at: now()) + invalidateRefresh() + await refresh() + } + + @discardableResult + func requestPresentation(_ request: PaykitPaymentRequest, isInitialSubscriptionPayment: Bool = false) -> Bool { discardExpiredRequests() guard pendingRequests.contains(where: { $0.id == request.id }), !processingRequestIds.contains(request.id), @@ -773,13 +1110,21 @@ final class PaykitPaymentRequestManager { presentationGeneration += 1 presentationRetryAttempts.removeValue(forKey: request.id) presentationRetryDates.removeValue(forKey: request.id) + if isInitialSubscriptionPayment { + initialSubscriptionPaymentRequestIds.insert(request.id) + } requestedPresentationId = request.id schedulePresentationRetry() return true } + func consumeInitialSubscriptionPayment(_ request: PaykitPaymentRequest) -> Bool { + initialSubscriptionPaymentRequestIds.remove(request.id) != nil + } + func clear() { stateGeneration += 1 + let clearedStateGeneration = stateGeneration presentationGeneration += 1 invalidateRefresh() eligibilityGeneration += 1 @@ -789,9 +1134,11 @@ final class PaykitPaymentRequestManager { presentationRetryTask = nil pendingRequests = [] historyRequests = [] + subscriptions = [] eligibleTargets = [] processingRequestIds = [] approvedPaymentRequestIds = [] + initialSubscriptionPaymentRequestIds = [] activeIdentity = nil savedPublicKeys = [] presentedRequestIds = [] @@ -799,7 +1146,20 @@ final class PaykitPaymentRequestManager { presentationRetryAttempts = [:] presentationRetryDates = [:] requestedPresentationId = nil + requestedSubscriptionProposalId = nil isCreatingRequest = false + isProcessingSubscription = false + subscriptionAcceptedAt = [:] + presentedSubscriptionProposalIds = [] + dismissedSubscriptionPaymentIds = [] + persistedSubscriptionState = PaykitSubscriptionState() + Task { @MainActor [weak self] in + guard let self, + stateGeneration == clearedStateGeneration, + activeIdentity == nil + else { return } + await subscriptionNotificationScheduler.cancel() + } } func requestsForPresentation() -> [PaykitPaymentRequest] { @@ -850,8 +1210,37 @@ final class PaykitPaymentRequestManager { approvedPaymentRequestIds.contains(request.id) } - func finishPayment(_ request: PaykitPaymentRequest) { + func finishPayment(_ request: PaykitPaymentRequest) async { approvedPaymentRequestIds.remove(request.id) + guard request.billingPeriod == nil, + let activeIdentity, + let acceptedRequest = historyRequests.first(where: { + $0.id == request.id && $0.direction == .incoming && $0.lifecycleState == .accepted + }) + else { return } + + async let completed = completedPaymentRequestIds(activeIdentity) + async let inFlight = inFlightPaymentRequestIds(activeIdentity) + let protectedRequestIds = await completed.union(inFlight) + guard !protectedRequestIds.contains(request.id), + !pendingRequests.contains(where: { $0.id == request.id }) + else { return } + + pendingRequests.append(acceptedRequest) + pendingRequests.sort { ($0.createdAt ?? .distantFuture) < ($1.createdAt ?? .distantFuture) } + } + + func paymentRequestForRetry(_ id: PaykitPaymentRequest.ID) -> PaykitPaymentRequest? { + approvedPaymentRequestIds.remove(id) + if let request = pendingRequests.first(where: { $0.id == id }) { + return request + } + guard let request = historyRequests.first(where: { + $0.id == id && $0.direction == .incoming && $0.lifecycleState == .accepted + }) else { return nil } + + pendingRequests.append(request) + return request } func deferPresentation(_ request: PaykitPaymentRequest) { @@ -904,18 +1293,82 @@ final class PaykitPaymentRequestManager { ) async { do { let snapshot = try await service.synchronize() - guard generation == refreshGeneration else { return } + guard generation == refreshGeneration, let activeIdentity else { return } + async let completedRequestIds = completedPaymentRequestIds(activeIdentity) + async let inFlightRequestIds = inFlightPaymentRequestIds(activeIdentity) + let (locallyCompletedRequestIds, locallyInFlightRequestIds) = await (completedRequestIds, inFlightRequestIds) + guard generation == refreshGeneration, + PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity) + else { return } + let refreshDate = now() + subscriptions = snapshot.subscriptions.map { $0.withExpiredLifecycle(at: refreshDate) } + let visibleProposalIds = Set(subscriptions.filter { $0.isProposalVisible(at: refreshDate) }.map(\.id)) + presentedSubscriptionProposalIds.formIntersection(visibleProposalIds) + for subscription in subscriptions + where subscription.wasAccepted && + subscriptionAcceptedAt[subscription.id] == nil + { + subscriptionAcceptedAt[subscription.id] = subscription.paidPeriods.map(\.startsAt).min() ?? subscription.createdAt ?? refreshDate + } + let recurringRequestsBySubscription = subscriptions.map { subscription in + let requests: [PaykitPaymentRequest] = if let acceptedAt = subscriptionAcceptedAt[subscription.id] { + subscription.requests(through: refreshDate, acceptedAt: acceptedAt) + } else { + [] + } + return (subscription, requests) + } + let activeRecurringRequestIds = Set(recurringRequestsBySubscription + .filter { $0.0.lifecycleState == .activeRecurring } + .flatMap { $0.1.map(\.id) }) + dismissedSubscriptionPaymentIds.formIntersection(activeRecurringRequestIds) + persistSubscriptionState() + let recurringPending = recurringRequestsBySubscription + .filter { $0.0.lifecycleState == .activeRecurring } + .flatMap { _, requests in + requests.filter { + $0.lifecycleState != .proofSubmitted && + !locallyCompletedRequestIds.contains($0.id) && + !locallyInFlightRequestIds.contains($0.id) && + !dismissedSubscriptionPaymentIds.contains($0.id) + } + } + .sorted { ($0.billingPeriod?.startsAt ?? .distantFuture) < ($1.billingPeriod?.startsAt ?? .distantFuture) } + let recurringHistory = recurringRequestsBySubscription.flatMap { _, requests in + requests.compactMap { request in + if request.lifecycleState == .proofSubmitted { + return request + } + return locallyCompletedRequestIds.contains(request.id) + ? request.updatingLifecycleState(.proofSubmitted) + : nil + } + } let protectedRequests = pendingRequests.filter { processingRequestIds.contains($0.id) && $0.id != excludingProtectedRequestId } - pendingRequests = snapshot.incoming + let oneTimePending = snapshot.incoming.filter { + !locallyCompletedRequestIds.contains($0.id) && + !locallyInFlightRequestIds.contains($0.id) && + !approvedPaymentRequestIds.contains($0.id) + } + pendingRequests = recurringPending + oneTimePending for request in protectedRequests where !pendingRequests.contains(where: { $0.id == request.id }) { pendingRequests.append(request) } - historyRequests = snapshot.history.sorted { + historyRequests = (snapshot.history + recurringHistory).sorted { ($0.createdAt ?? .distantPast) > ($1.createdAt ?? .distantPast) } + await subscriptionNotificationScheduler.synchronize( + subscriptions, + acceptedAt: subscriptionAcceptedAt, + pendingRequestIds: Set(pendingRequests.map(\.id)), + payerIdentity: activeIdentity, + notificationsEnabled: SettingsViewModel.shared.enableNotifications, + now: refreshDate + ) let requestIds = Set(pendingRequests.map(\.id)) + initialSubscriptionPaymentRequestIds.formIntersection(requestIds) presentedRequestIds.formIntersection(requestIds) presentationRetryAttempts = presentationRetryAttempts.filter { requestIds.contains($0.key) } presentationRetryDates = presentationRetryDates.filter { requestIds.contains($0.key) } @@ -935,10 +1388,38 @@ final class PaykitPaymentRequestManager { } } + private func applyCommittedSubscription(_ subscription: PaykitSubscription, at date: Date) async { + guard let activeIdentity else { return } + subscriptions.removeAll { $0.id == subscription.id } + subscriptions.append(subscription) + + let recurringRequests = subscriptionAcceptedAt[subscription.id].map { + subscription.requests(through: date, acceptedAt: $0) + } ?? [] + pendingRequests.removeAll { $0.belongs(to: subscription) } + if subscription.lifecycleState == .activeRecurring { + pendingRequests.append(contentsOf: recurringRequests.filter { $0.lifecycleState != .proofSubmitted }) + pendingRequests.sort { ($0.createdAt ?? .distantFuture) < ($1.createdAt ?? .distantFuture) } + } + historyRequests.removeAll { $0.belongs(to: subscription) } + historyRequests.append(contentsOf: recurringRequests.filter { $0.lifecycleState == .proofSubmitted }) + historyRequests.sort { ($0.createdAt ?? .distantPast) > ($1.createdAt ?? .distantPast) } + await subscriptionNotificationScheduler.synchronize( + subscriptions, + acceptedAt: subscriptionAcceptedAt, + pendingRequestIds: Set(pendingRequests.map(\.id)), + payerIdentity: activeIdentity, + notificationsEnabled: SettingsViewModel.shared.enableNotifications, + now: date + ) + discardExpiredRequests() + } + private func perform( _ request: PaykitPaymentRequest, resultingState: Paykit.PaymentRequestLifecycleState, markApprovedForPayment: Bool = false, + preservePending: Bool = false, operation: (PaykitPaymentRequest) async throws -> Void ) async throws { guard !request.isExpired(at: now()) else { @@ -961,10 +1442,13 @@ final class PaykitPaymentRequestManager { do { try await operation(request) guard actionGeneration == stateGeneration else { return } - invalidateRefresh() if markApprovedForPayment { approvedPaymentRequestIds.insert(request.id) } + if preservePending { + return + } + invalidateRefresh() let updatedRequest = request.updatingLifecycleState(resultingState) historyRequests.removeAll { $0.id == request.id } historyRequests.insert(updatedRequest, at: 0) @@ -996,7 +1480,18 @@ final class PaykitPaymentRequestManager { } private func discardExpiredRequests() { - pendingRequests.removeAll { $0.isExpired(at: now()) } + let date = now() + pendingRequests.removeAll { $0.isExpired(at: date) } + subscriptions = subscriptions.map { $0.withExpiredLifecycle(at: date) } + presentedSubscriptionProposalIds.formIntersection( + Set(subscriptions.filter { $0.isProposalVisible(at: date) }.map(\.id)) + ) + persistSubscriptionState() + if requestedSubscriptionProposalId.map({ id in + subscriptions.contains { $0.id == id && $0.isProposalVisible(at: date) } + }) == false { + requestedSubscriptionProposalId = nil + } let requestIds = Set(pendingRequests.map(\.id)) presentedRequestIds.formIntersection(requestIds) presentationRetryAttempts = presentationRetryAttempts.filter { requestIds.contains($0.key) } @@ -1032,7 +1527,13 @@ final class PaykitPaymentRequestManager { expirationTask?.cancel() expirationTask = nil - guard let nextExpiration = pendingRequests.compactMap(\.expiresAt).min() else { return } + let requestExpirations = pendingRequests.compactMap(\.expiresAt) + let subscriptionExpirations = 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())) expirationTask = Task { [weak self] in do { @@ -1045,6 +1546,23 @@ final class PaykitPaymentRequestManager { } } + private func persistSubscriptionState(identity: String? = nil) { + let subscriptionState = PaykitSubscriptionState( + acceptedAt: subscriptionAcceptedAt, + presentedProposalIds: presentedSubscriptionProposalIds, + dismissedPaymentIds: dismissedSubscriptionPaymentIds + ) + guard subscriptionState != persistedSubscriptionState, + let identity = identity ?? activeIdentity + else { return } + do { + try subscriptionStateStore.save(subscriptionState, identity: identity) + persistedSubscriptionState = subscriptionState + } catch { + logWarning("Failed to persist Paykit subscription state: \(error)") + } + } + private func persistPresentedRequestIds() { guard let activeIdentity, presentedRequestIds != persistedPresentedRequestIds else { return } do { diff --git a/Bitkit/Services/PaykitSubscription.swift b/Bitkit/Services/PaykitSubscription.swift new file mode 100644 index 000000000..1f347790b --- /dev/null +++ b/Bitkit/Services/PaykitSubscription.swift @@ -0,0 +1,706 @@ +import Foundation +import Paykit +import UserNotifications + +private struct PaykitPreciseInstant: Comparable, Hashable { + let seconds: Int64 + let nanoseconds: Int + let timestamp: String + + var date: Date { + Date(timeIntervalSince1970: Double(seconds) + Double(nanoseconds) / 1_000_000_000) + } + + init?(timestamp: String) { + let canonical = PaykitSubscriptionTimestamp.canonical(timestamp) + let fraction = PaykitSubscriptionTimestamp.fractionalSeconds(from: canonical) ?? "" + let wholeSecondTimestamp = canonical.firstIndex(of: ".").map { String(canonical[..<$0]) } ?? String(canonical.dropLast()) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + guard let date = formatter.date(from: wholeSecondTimestamp + "Z") else { return nil } + + seconds = Int64(date.timeIntervalSince1970) + nanoseconds = Int(fraction.padding(toLength: 9, withPad: "0", startingAt: 0)) ?? 0 + self.timestamp = canonical + } + + init(date: Date) { + var wholeSeconds = floor(date.timeIntervalSince1970) + var nanoseconds = Int(((date.timeIntervalSince1970 - wholeSeconds) * 1_000_000_000).rounded()) + if nanoseconds == 1_000_000_000 { + wholeSeconds += 1 + nanoseconds = 0 + } + seconds = Int64(wholeSeconds) + self.nanoseconds = nanoseconds + timestamp = PaykitSubscriptionTimestamp.string( + from: Date(timeIntervalSince1970: wholeSeconds), + fractionalSeconds: Self.fractionalSeconds(nanoseconds) + ) + } + + init(seconds: Int64, nanoseconds: Int) { + self.seconds = seconds + self.nanoseconds = nanoseconds + timestamp = PaykitSubscriptionTimestamp.string( + from: Date(timeIntervalSince1970: TimeInterval(seconds)), + fractionalSeconds: Self.fractionalSeconds(nanoseconds) + ) + } + + static func < (lhs: PaykitPreciseInstant, rhs: PaykitPreciseInstant) -> Bool { + (lhs.seconds, lhs.nanoseconds) < (rhs.seconds, rhs.nanoseconds) + } + + private static func fractionalSeconds(_ nanoseconds: Int) -> String? { + guard nanoseconds != 0 else { return nil } + return PaykitSubscriptionTimestamp.fractionalSeconds( + from: "1970-01-01T00:00:00." + String(format: "%09d", nanoseconds) + "Z" + ) + } +} + +struct PaykitBillingPeriod: Codable, Hashable { + let startsAt: Date + let endsAt: Date + private let startsAtTimestamp: String + private let endsAtTimestamp: String + + init?(sdkPeriod: Paykit.BillingPeriod) { + guard let preciseStartsAt = PaykitPreciseInstant(timestamp: sdkPeriod.startsAt), + let preciseEndsAt = PaykitPreciseInstant(timestamp: sdkPeriod.endsAt), + preciseStartsAt < preciseEndsAt + else { return nil } + + startsAt = preciseStartsAt.date + endsAt = preciseEndsAt.date + startsAtTimestamp = PaykitSubscriptionTimestamp.canonical(sdkPeriod.startsAt) + endsAtTimestamp = PaykitSubscriptionTimestamp.canonical(sdkPeriod.endsAt) + } + + init( + startsAt: Date, + endsAt: Date, + startsAtTimestamp: String? = nil, + endsAtTimestamp: String? = nil + ) { + self.startsAt = startsAt + self.endsAt = endsAt + self.startsAtTimestamp = startsAtTimestamp ?? PaykitSubscriptionTimestamp.string(from: startsAt) + self.endsAtTimestamp = endsAtTimestamp ?? PaykitSubscriptionTimestamp.string(from: endsAt) + } + + var sdkValue: Paykit.BillingPeriod { + Paykit.BillingPeriod( + startsAt: startsAtTimestamp, + endsAt: endsAtTimestamp + ) + } + + static func == (lhs: PaykitBillingPeriod, rhs: PaykitBillingPeriod) -> Bool { + lhs.startsAtTimestamp == rhs.startsAtTimestamp && lhs.endsAtTimestamp == rhs.endsAtTimestamp + } + + func hash(into hasher: inout Hasher) { + hasher.combine(startsAtTimestamp) + hasher.combine(endsAtTimestamp) + } +} + +struct PaykitSubscriptionMetadata: Hashable { + let description: String? + let benefits: [String] + + init(_ metadata: Paykit.PrivateJsonObject) { + guard let data = metadata.exportText().data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let subscription = object["subscription"] as? [String: Any], + subscription["version"] as? Int == 1 + else { + description = nil + benefits = [] + return + } + + description = Self.trimmed(subscription["description"] as? String, limit: 1024) + benefits = (subscription["benefits"] as? [String] ?? []) + .prefix(8) + .compactMap { Self.trimmed($0, limit: 160) } + } + + private static func trimmed(_ value: String?, limit: Int) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return String(trimmed.prefix(limit)) + } +} + +struct PaykitSubscriptionRecurrence: Hashable { + private static let maximumPeriods = 10000 + + enum Unit: String, Hashable { + case minute + case hour + case day + case week + case month + case year + + var isSupported: Bool { + switch self { + case .day, .week, .month, .year: + true + case .minute, .hour: + false + } + } + } + + let every: Int + let unit: Unit + let startsAt: Date + let anchor: Date + let endsAt: Date? + private let preciseStartsAt: PaykitPreciseInstant + private let preciseAnchor: PaykitPreciseInstant + private let preciseEndsAt: PaykitPreciseInstant? + + var canMaterializePeriods: Bool { + firstBoundaryIndex(after: preciseStartsAt) != nil + } + + init?(_ recurrence: Paykit.PaymentRequestRecurrence) { + guard let every = Int(exactly: recurrence.every), every > 0, + every <= Int.max / Self.maximumPeriods, + let unit = Unit(rawValue: recurrence.unit), + let preciseStartsAt = PaykitPreciseInstant(timestamp: recurrence.startsAt), + let preciseAnchor = PaykitPreciseInstant(timestamp: recurrence.anchor) + else { return nil } + + let preciseEndsAt = recurrence.endsAt.flatMap(PaykitPreciseInstant.init) + if recurrence.endsAt != nil, preciseEndsAt == nil || preciseEndsAt! <= preciseStartsAt { + return nil + } + + self.every = every + self.unit = unit + self.preciseStartsAt = preciseStartsAt + self.preciseAnchor = preciseAnchor + self.preciseEndsAt = preciseEndsAt + startsAt = preciseStartsAt.date + anchor = preciseAnchor.date + endsAt = preciseEndsAt?.date + } + + func periods(through date: Date, acceptedAt: Date) -> [PaykitBillingPeriod] { + let preciseDate = PaykitPreciseInstant(date: date) + let preciseAcceptedAt = PaykitPreciseInstant(date: acceptedAt) + guard unit.isSupported, preciseStartsAt <= preciseDate else { return [] } + + var periods: [PaykitBillingPeriod] = [] + var start = preciseStartsAt + guard var index = firstBoundaryIndex(after: preciseStartsAt) else { return [] } + + for _ in 0 ..< Self.maximumPeriods { + guard start <= preciseDate else { break } + guard var end = boundary(at: index) else { break } + index += 1 + if end <= start { + guard let fallback = addingInterval(to: start) else { break } + end = fallback + } + if let preciseEndsAt { + guard start < preciseEndsAt else { break } + end = min(end, preciseEndsAt) + } + guard end > start else { break } + + if end > preciseAcceptedAt { + periods.append(period(startsAt: start, endsAt: end)) + } + start = end + } + + return periods + } + + func nextPeriod(after date: Date) -> PaykitBillingPeriod? { + let preciseDate = PaykitPreciseInstant(date: date) + var start = preciseStartsAt + guard var index = firstBoundaryIndex(after: preciseStartsAt) else { return nil } + + for _ in 0 ..< Self.maximumPeriods { + guard var end = boundary(at: index) else { return nil } + index += 1 + if end <= start { + guard let fallback = addingInterval(to: start) else { return nil } + end = fallback + } + if let preciseEndsAt { + guard start < preciseEndsAt else { return nil } + end = min(end, preciseEndsAt) + } + if start > preciseDate { + return period(startsAt: start, endsAt: end) + } + start = end + } + return nil + } + + func upcomingPeriods(after date: Date, limit: Int) -> [PaykitBillingPeriod] { + guard limit > 0 else { return [] } + + var periods: [PaykitBillingPeriod] = [] + var cursor = date + for _ in 0 ..< min(limit, Self.maximumPeriods) { + guard let period = nextPeriod(after: cursor) else { break } + periods.append(period) + cursor = period.startsAt + } + return periods + } + + private func firstBoundaryIndex(after date: PaykitPreciseInstant) -> Int? { + var index = 0 + guard let anchorBoundary = boundary(at: index) else { return nil } + + if anchorBoundary > date { + while index > -Self.maximumPeriods, let previous = boundary(at: index - 1), previous > date { + index -= 1 + } + } else { + while index < Self.maximumPeriods, let candidate = boundary(at: index), candidate <= date { + index += 1 + } + } + + guard boundary(at: index).map({ $0 > date }) == true, + let previous = boundary(at: index - 1), previous <= date + else { return nil } + return index + } + + private func boundary(at index: Int) -> PaykitPreciseInstant? { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let value = every * index + + let boundaryDate: Date? = switch unit { + case .minute: + calendar.date(byAdding: .minute, value: value, to: preciseAnchor.date) + case .hour: + calendar.date(byAdding: .hour, value: value, to: preciseAnchor.date) + case .day: + calendar.date(byAdding: .day, value: value, to: preciseAnchor.date) + case .week: + calendar.date(byAdding: .weekOfYear, value: value, to: preciseAnchor.date) + case .month: + Self.monthBoundary(from: preciseAnchor.date, offset: value, calendar: calendar) + case .year: + Self.yearBoundary(from: preciseAnchor.date, offset: value, calendar: calendar) + } + guard let boundaryDate else { return nil } + return PaykitPreciseInstant(seconds: Int64(floor(boundaryDate.timeIntervalSince1970)), nanoseconds: preciseAnchor.nanoseconds) + } + + private func addingInterval(to date: PaykitPreciseInstant) -> PaykitPreciseInstant? { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let result: Date? = switch unit { + case .minute: + calendar.date(byAdding: .minute, value: every, to: date.date) + case .hour: + calendar.date(byAdding: .hour, value: every, to: date.date) + case .day: + calendar.date(byAdding: .day, value: every, to: date.date) + case .week: + calendar.date(byAdding: .weekOfYear, value: every, to: date.date) + case .month: + calendar.date(byAdding: .month, value: every, to: date.date) + case .year: + calendar.date(byAdding: .year, value: every, to: date.date) + } + guard let result else { return nil } + return PaykitPreciseInstant(seconds: Int64(floor(result.timeIntervalSince1970)), nanoseconds: date.nanoseconds) + } + + private func period(startsAt: PaykitPreciseInstant, endsAt: PaykitPreciseInstant) -> PaykitBillingPeriod { + PaykitBillingPeriod( + startsAt: startsAt.date, + endsAt: endsAt.date, + startsAtTimestamp: startsAt.timestamp, + endsAtTimestamp: endsAt.timestamp + ) + } + + private static func monthBoundary(from anchor: Date, offset: Int, calendar: Calendar) -> Date? { + let components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second, .nanosecond], from: anchor) + guard let firstOfAnchorMonth = calendar.date(from: DateComponents(year: components.year, month: components.month, day: 1)), + let targetMonth = calendar.date(byAdding: .month, value: offset, to: firstOfAnchorMonth), + let range = calendar.range(of: .day, in: .month, for: targetMonth) + else { return nil } + + var target = calendar.dateComponents([.year, .month], from: targetMonth) + target.day = min(components.day ?? 1, range.count) + target.hour = components.hour + target.minute = components.minute + target.second = components.second + target.nanosecond = components.nanosecond + return calendar.date(from: target) + } + + private static func yearBoundary(from anchor: Date, offset: Int, calendar: Calendar) -> Date? { + let components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second, .nanosecond], from: anchor) + guard let year = components.year, let month = components.month else { return nil } + let (targetYear, didOverflow) = year.addingReportingOverflow(offset) + guard !didOverflow, + let firstOfTargetMonth = calendar.date(from: DateComponents(year: targetYear, month: month, day: 1)), + let range = calendar.range(of: .day, in: .month, for: firstOfTargetMonth) + else { return nil } + + var target = DateComponents() + target.year = targetYear + target.month = month + target.day = min(components.day ?? 1, range.count) + target.hour = components.hour + target.minute = components.minute + target.second = components.second + target.nanosecond = components.nanosecond + return calendar.date(from: target) + } +} + +struct PaykitSubscription: Identifiable, Hashable { + struct ID: Codable, Hashable { + let paymentRequestId: String + let counterparty: String + let counterpartyReceiverPath: String + } + + let paymentRequestId: String + let counterparty: String + let counterpartyReceiverPath: String + let amountValue: String + let amountSats: UInt64 + let note: String? + let createdAt: Date? + let proposalExpiresAt: Date? + let recurrence: PaykitSubscriptionRecurrence + let metadata: PaykitSubscriptionMetadata + let acceptedPaymentEndpointIdentifiers: [String] + let wasAccepted: Bool + var lifecycleState: Paykit.PaymentRequestLifecycleState + let paidPeriods: [PaykitBillingPeriod] + + var id: ID { + ID( + paymentRequestId: paymentRequestId, + counterparty: counterparty, + counterpartyReceiverPath: counterpartyReceiverPath + ) + } + + var isProposal: Bool { + lifecycleState == .proposed + } + + func isProposalActionable(at date: Date) -> Bool { + isProposalVisible(at: date) && + recurrence.unit.isSupported && + recurrence.canMaterializePeriods && + !acceptedPaymentEndpointIdentifiers.isEmpty + } + + func isProposalVisible(at date: Date) -> Bool { + isProposal && + (proposalExpiresAt.map { $0 > date } ?? true) && + (recurrence.endsAt.map { $0 > date } ?? true) + } + + func isActive(at date: Date) -> Bool { + lifecycleState == .activeRecurring && recurrence.endsAt.map { $0 > date } ?? true + } + + func isExpired(at date: Date) -> Bool { + lifecycleState == .canceled || lifecycleState == .rejected || lifecycleState == .proposalExpired || + (isProposal && proposalExpiresAt.map { $0 <= date } ?? false) || + (recurrence.endsAt.map { $0 <= date } ?? false) + } + + func withExpiredLifecycle(at date: Date) -> PaykitSubscription { + guard isProposal, + proposalExpiresAt.map({ $0 <= date }) == true || recurrence.endsAt.map({ $0 <= date }) == true + else { return self } + var subscription = self + subscription.lifecycleState = .proposalExpired + return subscription + } + + init?(record: Paykit.PaymentRequestRecord) { + guard record.localRole == .payer, + let terms = record.terms, + let recurrence = terms.recurrence.flatMap(PaykitSubscriptionRecurrence.init), + terms.amount.asset == "btc", + let amountSats = PaykitPaymentRequest.sats(fromBitcoinAmount: terms.amount.value), + amountSats <= UInt64.max / 1000 + else { return nil } + + let proposalExpiresAt = terms.proposalExpiresAt.flatMap(PaykitPaymentRequest.parseDate) + if terms.proposalExpiresAt != nil, proposalExpiresAt == nil { return nil } + + paymentRequestId = record.paymentRequestId + counterparty = record.counterparty + counterpartyReceiverPath = record.counterpartyReceiverPath + amountValue = terms.amount.value + self.amountSats = amountSats + note = PaykitPaymentRequest.note(from: terms.metadata).map { String($0.prefix(256)) } + createdAt = record.lastEventAt.flatMap(PaykitPaymentRequest.parseDate) + self.proposalExpiresAt = proposalExpiresAt + self.recurrence = recurrence + metadata = PaykitSubscriptionMetadata(terms.metadata) + acceptedPaymentEndpointIdentifiers = PaykitPaymentRequest.supportedEndpointIdentifiers( + terms.acceptedPaymentEndpointIdentifiers + ) + wasAccepted = record.acceptedEventId != nil || record.state == .activeRecurring || !record.paymentProofs.isEmpty + lifecycleState = record.state + paidPeriods = record.paymentProofs.compactMap { proof in + proof.billingPeriod.flatMap(PaykitBillingPeriod.init) + } + } + + func requests(through date: Date, acceptedAt: Date) -> [PaykitPaymentRequest] { + recurrence.periods(through: date, acceptedAt: acceptedAt).map { period in + let isPaid = paidPeriods.contains(period) + return PaykitPaymentRequest( + subscription: self, + billingPeriod: period, + lifecycleState: isPaid ? .proofSubmitted : .activeRecurring + ) + } + } + + func paymentDueOnAcceptance(at date: Date) -> PaykitPaymentRequest? { + guard let period = recurrence.periods(through: date, acceptedAt: date).first else { return nil } + return PaykitPaymentRequest(subscription: self, billingPeriod: period, lifecycleState: .activeRecurring) + } +} + +struct PaykitSubscriptionState: Codable, Equatable { + var acceptedAt: [PaykitSubscription.ID: Date] = [:] + var presentedProposalIds: Set = [] + var dismissedPaymentIds: Set = [] +} + +protocol PaykitSubscriptionStateStoring { + func load(identity: String) throws -> PaykitSubscriptionState + func save(_ subscriptionState: PaykitSubscriptionState, identity: String) throws +} + +struct PaykitSubscriptionStateStore: PaykitSubscriptionStateStoring { + private struct State: Codable { + var subscriptionsByIdentity: [String: PaykitSubscriptionState] + } + + func load(identity: String) throws -> PaykitSubscriptionState { + guard let normalizedIdentity = PubkyPublicKeyFormat.normalized(identity), + let data = try Keychain.load(key: .paykitSubscriptionState) + else { return PaykitSubscriptionState() } + + return try JSONDecoder().decode(State.self, from: data).subscriptionsByIdentity[normalizedIdentity] ?? PaykitSubscriptionState() + } + + func save(_ subscriptionState: PaykitSubscriptionState, identity: String) throws { + guard let normalizedIdentity = PubkyPublicKeyFormat.normalized(identity) else { return } + var state: State = if let data = try Keychain.load(key: .paykitSubscriptionState) { + try JSONDecoder().decode(State.self, from: data) + } else { + State(subscriptionsByIdentity: [:]) + } + state.subscriptionsByIdentity[normalizedIdentity] = subscriptionState + try Keychain.upsert(key: .paykitSubscriptionState, data: JSONEncoder().encode(state)) + } +} + +protocol PaykitSubscriptionNotificationCenter: Sendable { + func pendingNotificationRequests() async -> [UNNotificationRequest] + func add(_ request: UNNotificationRequest) async throws + func removePendingNotificationRequests(withIdentifiers identifiers: [String]) async +} + +private struct SystemPaykitSubscriptionNotificationCenter: PaykitSubscriptionNotificationCenter { + func pendingNotificationRequests() async -> [UNNotificationRequest] { + await UNUserNotificationCenter.current().pendingNotificationRequests() + } + + func add(_ request: UNNotificationRequest) async throws { + try await UNUserNotificationCenter.current().add(request) + } + + func removePendingNotificationRequests(withIdentifiers identifiers: [String]) async { + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: identifiers) + } +} + +actor PaykitSubscriptionNotificationScheduler { + private static let maximumNotifications = 32 + private let center: any PaykitSubscriptionNotificationCenter + private var generation = 0 + private var retainedIdentifiers: Set = [] + + init(center: any PaykitSubscriptionNotificationCenter = SystemPaykitSubscriptionNotificationCenter()) { + self.center = center + } + + func synchronize( + _ subscriptions: [PaykitSubscription], + acceptedAt: [PaykitSubscription.ID: Date], + pendingRequestIds: Set, + payerIdentity: String, + notificationsEnabled: Bool, + now: Date + ) async { + generation += 1 + let currentGeneration = generation + let notifications: [(PaykitSubscription, PaykitBillingPeriod)] = notificationsEnabled ? Array(subscriptions + .filter { + $0.isActive(at: now) && + $0.recurrence.unit.isSupported && + acceptedAt[$0.id] != nil + } + .flatMap { subscription in + subscription.recurrence.upcomingPeriods( + after: now, + limit: Self.maximumNotifications + ).map { (subscription, $0) } + } + .sorted { $0.1.startsAt < $1.1.startsAt } + .prefix(Self.maximumNotifications)) : [] + + let desiredIdentifiers = Set(notifications.map { + PaykitSubscriptionNotificationIdentifier.identifier(identity: payerIdentity, subscription: $0.0, period: $0.1) + }) + let unpaidIdentifiers: Set = notificationsEnabled ? Set(pendingRequestIds.compactMap { + PaykitSubscriptionNotificationIdentifier.identifier(identity: payerIdentity, requestId: $0) + }) : [] + retainedIdentifiers = desiredIdentifiers.union(unpaidIdentifiers) + + let pending = await center.pendingNotificationRequests() + guard generation == currentGeneration else { return } + + let existingIdentifiers = Set(pending.map(\.identifier)) + await center.removePendingNotificationRequests( + withIdentifiers: existingIdentifiers.filter { + $0.hasPrefix(PaykitSubscriptionNotificationIdentifier.prefix) && !retainedIdentifiers.contains($0) + } + ) + + for (subscription, period) in notifications { + guard generation == currentGeneration else { return } + let identifier = PaykitSubscriptionNotificationIdentifier.identifier( + identity: payerIdentity, + subscription: subscription, + period: period + ) + guard !existingIdentifiers.contains(identifier) else { continue } + let content = UNMutableNotificationContent() + content.title = t("subscriptions__payment_due_title") + content.body = t("subscriptions__payment_due_description") + content.sound = .default + content.userInfo = [ + "bitkit_action": "paykit_subscription_due", + "payer_identity": payerIdentity, + "payment_request_id": subscription.paymentRequestId, + "counterparty": subscription.counterparty, + "counterparty_receiver_path": subscription.counterpartyReceiverPath, + "billing_period_starts_at": PaykitSubscriptionTimestamp.string(from: period.startsAt), + ] + let interval = max(1, period.startsAt.timeIntervalSince(now)) + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: interval, repeats: false) + let request = UNNotificationRequest( + identifier: identifier, + content: content, + trigger: trigger + ) + try? await center.add(request) + guard generation == currentGeneration else { + if !retainedIdentifiers.contains(identifier) { + await center.removePendingNotificationRequests(withIdentifiers: [identifier]) + } + return + } + } + } + + func cancel() async { + generation += 1 + let currentGeneration = generation + retainedIdentifiers = [] + let pending = await center.pendingNotificationRequests() + guard generation == currentGeneration else { return } + await center.removePendingNotificationRequests( + withIdentifiers: pending.map(\.identifier).filter { $0.hasPrefix(PaykitSubscriptionNotificationIdentifier.prefix) } + ) + } +} + +enum PaykitSubscriptionNotificationIdentifier { + static let prefix = "paykit-subscription-" + + static func identifier(identity: String, subscription: PaykitSubscription, period: PaykitBillingPeriod) -> String { + "\(prefix)\(identity)|\(subscription.counterparty)|" + + "\(subscription.counterpartyReceiverPath)|\(subscription.paymentRequestId)|" + + PaykitSubscriptionTimestamp.string(from: period.startsAt) + } + + static func identifier(identity: String, requestId: PaykitPaymentRequest.ID) -> String? { + guard let startsAt = requestId.billingPeriodStartsAt else { return nil } + return "\(prefix)\(identity)|\(requestId.counterparty)|" + + "\(requestId.counterpartyReceiverPath)|\(requestId.paymentRequestId)|" + + PaykitSubscriptionTimestamp.string(from: startsAt) + } +} + +enum PaykitSubscriptionTimestamp { + static func string(from date: Date) -> String { + string(from: date, fractionalSeconds: nil) + } + + static func string(from date: Date, fractionalSeconds: String?) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.calendar = Calendar(identifier: .gregorian) + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + return formatter.string(from: date) + (fractionalSeconds.map { ".\($0)" } ?? "") + "Z" + } + + static func fractionalSeconds(from timestamp: String) -> String? { + let timestamp = canonical(timestamp) + guard let periodIndex = timestamp.firstIndex(of: "."), + let zoneIndex = timestamp[periodIndex...].firstIndex(of: "Z") + else { return nil } + let fraction = timestamp[timestamp.index(after: periodIndex) ..< zoneIndex] + return fraction.isEmpty ? nil : String(fraction) + } + + static func canonical(_ timestamp: String) -> String { + guard timestamp.hasSuffix("Z"), + let periodIndex = timestamp.firstIndex(of: ".") + else { return timestamp } + + let fractionStart = timestamp.index(after: periodIndex) + let zoneIndex = timestamp.index(before: timestamp.endIndex) + let rawFraction = timestamp[fractionStart ..< zoneIndex] + guard !rawFraction.isEmpty, rawFraction.allSatisfy(\.isNumber) else { return timestamp } + + let nanoseconds = String(rawFraction.prefix(9)).padding(toLength: 9, withPad: "0", startingAt: 0) + guard let lastNonzeroIndex = nanoseconds.lastIndex(where: { $0 != "0" }) else { + return String(timestamp[.. PublicPaykitPaymentLaunchResult { + var result = try await beginPaymentRequest(request) + for delay in Self.privatePaymentResolutionRetryDelays { + guard case .waitingForUpdatedPaymentList = result else { return result } + try await Task.sleep(nanoseconds: delay) + result = try await beginPaymentRequest(request) + } + return result + } + private func beginContactPayment( to publicKey: String, receiverPath: String, diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 4b5045f44..70af505b8 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -759,6 +759,22 @@ actor PaykitSdkService { } } + func cancelPaymentRequest( + counterparty: String, + counterpartyReceiverPath: String, + paymentRequestId: String, + reason: String? = nil + ) async throws -> Paykit.PaymentRequestRecord { + try await withStateRevisionTracking { sdk in + try await sdk.cancelPaymentRequest( + counterparty: counterparty, + counterpartyReceiverPath: counterpartyReceiverPath, + paymentRequestId: paymentRequestId, + reason: reason + ) + } + } + func linkedPeers() async throws -> [LinkedPeerRecord] { try await operationLock.withLock { try await handle().linkedPeers() diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift index f3ff75d0d..584e60e17 100644 --- a/Bitkit/Utilities/Keychain.swift +++ b/Bitkit/Utilities/Keychain.swift @@ -9,6 +9,7 @@ enum KeychainEntryType { case paykitSession case paykitPendingPaymentProofs case paykitPresentedPaymentRequests + case paykitSubscriptionState case paykitReceiverNoiseSecretKey case paykitSdkState case pubkySecretKey @@ -22,6 +23,7 @@ enum KeychainEntryType { case .paykitSession: "paykit_session" case .paykitPendingPaymentProofs: "paykit_pending_payment_proofs" case .paykitPresentedPaymentRequests: "paykit_presented_payment_requests" + case .paykitSubscriptionState: "paykit_subscription_state" case .paykitReceiverNoiseSecretKey: "paykit_receiver_noise_secret_key" case .paykitSdkState: "paykit_sdk_state" case .pubkySecretKey: "pubky_secret_key" diff --git a/Bitkit/Utilities/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift index a9c30fd11..93f7210e9 100644 --- a/Bitkit/Utilities/PaymentNavigationHelper.swift +++ b/Bitkit/Utilities/PaymentNavigationHelper.swift @@ -180,4 +180,60 @@ struct PaymentNavigationHelper { return route } } + + static func openPrivateContactPayment( + publicKey: String, + app: AppViewModel, + currency: CurrencyViewModel, + settings: SettingsViewModel, + wallet: WalletViewModel, + present: (SendRoute) -> Void + ) async { + do { + let result = try await PrivatePaykitService.shared.beginSavedContactPayment(to: publicKey, wallet: wallet) + switch result { + case let .opened(paymentRequest, privatePaymentContext): + let context = ContactPaymentContext(publicKey: publicKey, privatePaymentContext: privatePaymentContext) + guard app.claimContactPaymentContext(context) else { return } + + do { + try await app.handleScannedData(paymentRequest, claimedContactPaymentContext: context) + } catch is CancellationError { + if app.ownsContactPaymentContext(context) { + app.resetSendState() + } + return + } catch { + guard app.ownsContactPaymentContext(context) else { return } + app.resetSendState() + Logger.warn("Failed to decode private contact payment request", context: "PaymentNavigationHelper") + app.toast( + type: .warning, + title: t("slashtags__error_pay_title"), + description: t("slashtags__error_pay_not_opened_msg") + ) + return + } + + guard app.ownsContactPaymentContext(context), + let route = contactPaymentRoute(app: app, currency: currency, settings: settings) + else { + app.resetSendState() + return + } + present(route) + + case .noEndpoint, .notOpened, .waitingForUpdatedPaymentList: + if let messageKey = result.contactPaymentFailureMessageKey { + app.toast(type: .warning, title: t("slashtags__error_pay_title"), description: t(messageKey)) + } + } + } catch { + Logger.error( + "Failed to pay contact \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", + context: "PaymentNavigationHelper" + ) + app.toast(type: .error, title: t("slashtags__error_pay_title"), description: error.localizedDescription) + } + } } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index fd2fe711d..d4a09eb54 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -22,17 +22,20 @@ struct ContactPaymentContext: Equatable { let publicKey: String let privatePaymentContext: PrivatePaykitPaymentContext? let incomingPaymentRequest: PaykitPaymentRequest? + let isInitialSubscriptionPayment: Bool init( id: UUID = UUID(), publicKey: String, privatePaymentContext: PrivatePaykitPaymentContext? = nil, - incomingPaymentRequest: PaykitPaymentRequest? = nil + incomingPaymentRequest: PaykitPaymentRequest? = nil, + isInitialSubscriptionPayment: Bool = false ) { self.id = id self.publicKey = publicKey self.privatePaymentContext = privatePaymentContext self.incomingPaymentRequest = incomingPaymentRequest + self.isInitialSubscriptionPayment = isInitialSubscriptionPayment } } @@ -363,17 +366,17 @@ extension AppViewModel { // MARK: Pending payment tracking extension AppViewModel { - func addPendingPaymentHash(_ hash: String, contactPublicKey: String? = nil) { + func addPendingPaymentHash(_ hash: String, contactPaymentContext: ContactPaymentContext? = nil) { pendingPaymentHashes.insert(hash) - if let contactPublicKey { - pendingContactPaymentContexts[hash] = ContactPaymentContext(publicKey: contactPublicKey) + if let contactPaymentContext { + pendingContactPaymentContexts[hash] = contactPaymentContext } } - func addPendingContactPaymentContext(_ hash: String, contactPublicKey: String?) { - guard let contactPublicKey else { return } - pendingContactPaymentContexts[hash] = ContactPaymentContext(publicKey: contactPublicKey) + func addPendingContactPaymentContext(_ hash: String, context: ContactPaymentContext?) { + guard let context else { return } + pendingContactPaymentContexts[hash] = context } func contactPaymentContext(forPendingPaymentHash hash: String) -> ContactPaymentContext? { @@ -1059,18 +1062,22 @@ extension AppViewModel { } case let .paymentFailed(paymentId, paymentHash, reason): let hash = paymentId ?? paymentHash - if let paymentHash = paymentHash ?? paymentId { - Task { await PaykitPaymentProofService.shared.failLightningPayment(paymentHash: paymentHash) } - } if let hash, pendingPaymentHashes.contains(hash) { pendingPaymentHashes.remove(hash) - sendSheetPendingResolution = SendSheetPendingResolution(paymentHash: hash, success: false, failureReason: reason) - toast( - type: .error, - title: t("wallet__toast_payment_failed_title"), - description: t("wallet__payment_failed_description"), - accessibilityIdentifier: "PaymentFailedToast" - ) + Task { @MainActor in + if let paymentHash = paymentHash ?? paymentId { + await PaykitPaymentProofService.shared.failLightningPayment(paymentHash: paymentHash) + } + sendSheetPendingResolution = SendSheetPendingResolution(paymentHash: hash, success: false, failureReason: reason) + toast( + type: .error, + title: t("wallet__toast_payment_failed_title"), + description: t("wallet__payment_failed_description"), + accessibilityIdentifier: "PaymentFailedToast" + ) + } + } else if let paymentHash = paymentHash ?? paymentId { + Task { await PaykitPaymentProofService.shared.failLightningPayment(paymentHash: paymentHash) } } case .paymentClaimable: break diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index ba841f693..3ac577f48 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -32,7 +32,9 @@ enum Route: Hashable { case createProfile case editProfile case payContacts - case paymentRequests + case subscriptions(showPayments: Bool) + case paymentRequestDetail(PaykitPaymentRequest.ID) + case subscriptionDetail(PaykitSubscription.ID) case transferIntro case fundingOptions case spendingIntro diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index daf8581ec..055547853 100644 --- a/Bitkit/ViewModels/SheetViewModel.swift +++ b/Bitkit/ViewModels/SheetViewModel.swift @@ -15,6 +15,7 @@ enum SheetID: String, CaseIterable { case pubkyAuthApproval case notifications case paymentRequests + case subscription case quickpay case receive case receivedTx @@ -297,6 +298,18 @@ class SheetViewModel: ObservableObject { } } + var subscriptionSheetItem: SubscriptionSheetItem? { + get { + guard let config = activeSheetConfiguration, config.id == .subscription else { return nil } + return config.data as? SubscriptionSheetItem + } + set { + if newValue == nil { + activeSheetConfiguration = nil + } + } + } + var quickpaySheetItem: QuickpaySheetItem? { get { guard let config = activeSheetConfiguration, config.id == .quickpay else { return nil } diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 81be0e66b..5a40b14bd 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -593,7 +593,12 @@ class WalletViewModel: ObservableObject { /// - isMaxAmount: Whether this is a max amount send (uses sendAllToAddress) /// - Returns: The transaction ID (txid) of the sent transaction /// - Throws: An error if the transaction fails or if fee rates cannot be retrieved - func send(address: String, sats: UInt64, isMaxAmount: Bool = false) async throws -> Txid { + func send( + address: String, + sats: UInt64, + isMaxAmount: Bool = false, + beforeBroadcastAttempt: () async throws -> Void = {} + ) async throws -> Txid { guard let selectedFeeRateSatsPerVByte else { throw AppError(message: "Fee rate not set", debugMessage: "Please set a fee rate before selecting UTXOs.") } @@ -604,6 +609,7 @@ class WalletViewModel: ObservableObject { Logger.warn("No UTXO selected, using default selection algorithm.") } + try await beforeBroadcastAttempt() let txid = try await lightningService.send( address: address, sats: sats, diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift index ae84a9890..40ed88f4c 100644 --- a/Bitkit/Views/Contacts/ContactDetailView.swift +++ b/Bitkit/Views/Contacts/ContactDetailView.swift @@ -1,6 +1,8 @@ import SwiftUI struct ContactDetailView: View { + @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false + @EnvironmentObject var app: AppViewModel @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var navigation: NavigationViewModel @@ -8,6 +10,7 @@ struct ContactDetailView: View { @EnvironmentObject var settings: SettingsViewModel @EnvironmentObject var sheets: SheetViewModel @EnvironmentObject var wallet: WalletViewModel + @Environment(PaykitPaymentRequestManager.self) private var paymentRequests let publicKey: String var showsDeleteAction = false @@ -111,8 +114,15 @@ struct ContactDetailView: View { private var contactActions: some View { HStack(spacing: 16) { GradientCircleButton(icon: "coins", accessibilityLabel: t("wallet__send")) { - Task { - await payContact() + if canRequestPayment { + sheets.showSheet( + .receive, + data: ReceiveConfig(view: .requestOrPay(publicKey: publicKey)) + ) + } else { + Task { + await payContact() + } } } .accessibilityIdentifier("ContactPay") @@ -147,6 +157,14 @@ struct ContactDetailView: View { } } + private var canRequestPayment: Bool { + PaykitFeatureFlags.isUIAvailable && + isPaykitUIEnabled && + paymentRequests.eligibleTargets.contains { + PubkyPublicKeyFormat.matches($0.publicKey, publicKey) + } + } + // MARK: - Links / Metadata private func linksSection(_ profile: PubkyProfile) -> some View { @@ -304,69 +322,15 @@ struct ContactDetailView: View { } private func payContact() async { - do { - let result = try await PrivatePaykitService.shared.beginSavedContactPayment(to: publicKey, wallet: wallet) - - switch result { - case let .opened(paymentRequest, privatePaymentContext): - _ = await openContactPayment(paymentRequest: paymentRequest, privatePaymentContext: privatePaymentContext) - case .noEndpoint, .notOpened, .waitingForUpdatedPaymentList: - if let messageKey = result.contactPaymentFailureMessageKey { - app.toast( - type: .warning, - title: t("slashtags__error_pay_title"), - description: t(messageKey) - ) - } - } - } catch { - Logger.error("Failed to pay contact \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", context: "ContactDetailView") - app.toast( - type: .error, - title: t("slashtags__error_pay_title"), - description: error.localizedDescription - ) - } - } - - @MainActor - private func openContactPayment(paymentRequest: String, privatePaymentContext: PrivatePaykitPaymentContext?) async -> Bool { - let contactPaymentContext = ContactPaymentContext( + await PaymentNavigationHelper.openPrivateContactPayment( publicKey: publicKey, - privatePaymentContext: privatePaymentContext - ) - guard app.claimContactPaymentContext(contactPaymentContext) else { return false } - - do { - try await app.handleScannedData( - paymentRequest, - claimedContactPaymentContext: contactPaymentContext - ) - } catch is CancellationError { - if app.ownsContactPaymentContext(contactPaymentContext) { - app.resetSendState() - } - return false - } catch { - guard app.ownsContactPaymentContext(contactPaymentContext) else { return false } - app.resetSendState() - Logger.warn("Failed to decode contact payment request: \(error)", context: "ContactDetailView") - app.toast( - type: .warning, - title: t("slashtags__error_pay_title"), - description: t("slashtags__error_pay_not_opened_msg") - ) - return false + app: app, + currency: currency, + settings: settings, + wallet: wallet + ) { route in + sheets.showSheet(.send, data: SendConfig(view: route)) } - - guard app.ownsContactPaymentContext(contactPaymentContext) else { return false } - guard let route = PaymentNavigationHelper.contactPaymentRoute(app: app, currency: currency, settings: settings) else { - app.resetSendState() - return false - } - - sheets.showSheet(.send, data: SendConfig(view: route)) - return true } } diff --git a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift index 8c00d6771..34a264dd3 100644 --- a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift +++ b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift @@ -35,127 +35,96 @@ enum PaymentRequestExpiration: String, CaseIterable, CustomStringConvertible, Id } } -struct PaymentRequestDetailsView: View { +struct RequestOrPayView: View { + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var contactsManager: ContactsManager @EnvironmentObject private var currency: CurrencyViewModel + @EnvironmentObject private var settings: SettingsViewModel + @EnvironmentObject private var sheets: SheetViewModel + @EnvironmentObject private var wallet: WalletViewModel + @Environment(PaykitPaymentRequestManager.self) private var paymentRequests - let initialDraft: PaykitPaymentRequestDraft - let onContinue: (PaykitPaymentRequestDraft) -> Void - - @State private var amountViewModel = AmountInputViewModel() - @State private var note = "" - @State private var expiration = PaymentRequestExpiration.week - @State private var isAmountInputFocused = false - @FocusState private var isNoteFocused: Bool - - var body: some View { - VStack(spacing: 0) { - SheetHeader(title: t("wallet__payment_request"), showBackButton: true) - - VStack(alignment: .leading, spacing: 0) { - CaptionMText(t("wallet__payment_request_amount"), textColor: .white64) - .padding(.bottom, 8) - - NumberPadTextField( - viewModel: amountViewModel, - showEditButton: !isAmountInputFocused, - isFocused: isAmountInputFocused, - testIdentifier: "PaymentRequestAmountField" - ) - .onTapGesture { - if isAmountInputFocused { - amountViewModel.togglePrimaryDisplay(currency: currency) - } else { - isAmountInputFocused = true - } - } - - if !isAmountInputFocused { - CaptionMText(t("wallet__note"), textColor: .white64) - .padding(.top, 24) - .padding(.bottom, 8) + let publicKey: String + let onRequest: (PaykitPaymentRequestTarget) -> Void - NoteTextEditor( - text: $note, - placeholder: t("wallet__receive_note_placeholder"), - testIdentifier: "PaymentRequestNote", - isFocused: $isNoteFocused - ) + private var target: PaykitPaymentRequestTarget? { + paymentRequests.eligibleTargets.first { PubkyPublicKeyFormat.matches($0.publicKey, publicKey) } + } - CaptionMText(t("wallet__payment_request_expires"), textColor: .white64) - .padding(.top, 24) - .padding(.bottom, 12) + private var contactName: String { + contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, publicKey) }?.displayName + ?? PubkyPublicKeyFormat.displayTruncated(publicKey) + } - expirationPicker + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SheetHeader(title: t("wallet__payment_request_or_pay")) - Spacer() + Spacer() - CustomButton( - title: t("wallet__payment_request_choose_recipient"), - isDisabled: amountViewModel.amountSats == 0 - ) { - let draft = PaykitPaymentRequestDraft( - amountSats: amountViewModel.amountSats, - note: note.trimmingCharacters(in: .whitespacesAndNewlines), - expiresAt: expiration.date(from: Date()) - ) - onContinue(draft) - } - .buttonBottomPadding(isFocused: isNoteFocused) - .accessibilityIdentifier("PaymentRequestAmountContinue") - } - } + Image("coin-stack-4") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 256, height: 256) + .frame(maxWidth: .infinity) + .accessibilityHidden(true) - if isAmountInputFocused { - Spacer() + Spacer() - NumberPad( - type: amountViewModel.getNumberPadType(currency: currency), - errorKey: amountViewModel.errorKey - ) { key in - amountViewModel.handleNumberPadInput(key, currency: currency) + DisplayText(t("wallet__payment_request_or_pay_headline"), accentColor: .purpleAccent) + .padding(.bottom, 8) + BodyMText( + t("wallet__payment_request_or_pay_description", variables: ["contact": contactName]), + textColor: .white64 + ) + .padding(.bottom, 24) + + HStack(spacing: 16) { + CustomButton( + title: t("common__pay"), + variant: .secondary, + icon: Image("arrow-up").resizable().frame(width: 16, height: 16) + ) { + await payContact() } - - CustomButton(title: t("common__continue"), isDisabled: amountViewModel.amountSats == 0) { - isAmountInputFocused = false + CustomButton( + title: t("wallet__payment_request_request"), + icon: Image("arrow-down").resizable().frame(width: 16, height: 16), + isDisabled: target == nil + ) { + if let target { + onRequest(target) + } } } } .padding(.horizontal, 16) .sheetBackground() .navigationBarHidden(true) - .task { - amountViewModel.updateFromSats(initialDraft.amountSats, currency: currency) - note = initialDraft.note - if initialDraft.expiresAt > Date() { - expiration = .closest(to: initialDraft.expiresAt, from: Date()) - } - } - .onChange(of: note) { _, value in - if value.count > 256 { - note = String(value.prefix(256)) - } - } + .accessibilityIdentifier("RequestOrPay") } - private var expirationPicker: some View { - SegmentedControl( - selectedTab: $expiration, - tabs: PaymentRequestExpiration.allCases, - activeColor: .textPrimary - ) + private func payContact() async { + await PaymentNavigationHelper.openPrivateContactPayment( + publicKey: publicKey, + app: app, + currency: currency, + settings: settings, + wallet: wallet + ) { route in + sheets.hideSheetBeforePerforming(reason: "Opening contact payment") { + sheets.showSheet(.send, data: SendConfig(view: route)) + } + } } } struct PaymentRequestRecipientView: View { - @EnvironmentObject private var app: AppViewModel @EnvironmentObject private var contactsManager: ContactsManager @Environment(PaykitPaymentRequestManager.self) private var paymentRequests - let draft: PaykitPaymentRequestDraft - let onEditExpiration: () -> Void - let onSent: (PaykitPaymentRequest) -> Void + let onSelect: (PaykitPaymentRequestTarget) -> Void - @State private var selectedTarget: PaykitPaymentRequestTarget? @State private var recipientQuery = "" private var recipientTargets: [PaykitPaymentRequestTarget] { @@ -171,23 +140,33 @@ struct PaymentRequestRecipientView: View { } } - private var allRecipientTargets: [PaykitPaymentRequestTarget] { - paymentRequests.eligibleTargets - } + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: t("wallet__payment_request_choose_recipient"), showBackButton: true) - private var isSelectionAvailable: Bool { - guard let selectedTarget else { return false } - return allRecipientTargets.contains(selectedTarget) - } + recipientInput + .padding(.bottom, 16) - private var canSend: Bool { - isSelectionAvailable && !paymentRequests.isCreatingRequest + ScrollView(showsIndicators: false) { + LazyVStack(alignment: .leading, spacing: 0) { + CaptionMText(t("contacts__nav_title").localizedUppercase, textColor: .white64) + .padding(.vertical, 16) + CustomDivider() + ForEach(recipientTargets) { target in + recipientRow(target) + } + } + } + } + .padding(.horizontal, 16) + .sheetBackground() + .navigationBarHidden(true) + .accessibilityIdentifier("PaymentRequestRecipient") } - private var recipientSection: some View { - VStack(alignment: .leading, spacing: 0) { + private var recipientInput: some View { + VStack(alignment: .leading, spacing: 8) { CaptionMText(t("wallet__payment_request_recipient").localizedUppercase, textColor: .white64) - .padding(.bottom, 8) HStack(spacing: 8) { TextField( @@ -210,107 +189,265 @@ struct PaymentRequestRecipientView: View { Image("clipboard") .resizable() .scaledToFit() - .frame(width: 24, height: 24) + .frame(width: 16, height: 16) .accessibilityHidden(true) - BodyMSBText(t("common__paste")) } - .foregroundColor(.textPrimary) } .buttonStyle(.plain) - .padding(.trailing, 16) .accessibilityIdentifier("PaymentRequestRecipientPaste") } + .padding(16) .background(Color.white08) - .cornerRadius(8) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } - CaptionMText(t("contacts__nav_title").localizedUppercase, textColor: .white64) - .padding(.top, 32) - .padding(.bottom, 16) + private func contact(for target: PaykitPaymentRequestTarget) -> PubkyContact? { + contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, target.publicKey) } + } + + private func displayName(for target: PaykitPaymentRequestTarget) -> String { + contact(for: target)?.displayName ?? target.publicKey + } + @ViewBuilder + private func recipientRow(_ target: PaykitPaymentRequestTarget) -> some View { + if let contact = contact(for: target) { + PubkyContactRow(contact: contact, verticalPadding: 20) { + onSelect(target) + } + .accessibilityIdentifier("PaymentRequestContact-\(contact.publicKey)") + } else { + Button { + onSelect(target) + } label: { + HStack(spacing: 16) { + ContactAvatarLetter(source: target.publicKey, size: 48) + VStack(alignment: .leading, spacing: 4) { + CaptionText(PubkyPublicKeyFormat.displayTruncated(target.publicKey).localizedUppercase) + BodyMSBText(PubkyPublicKeyFormat.displayTruncated(target.publicKey)) + } + Spacer() + } + .padding(.vertical, 20) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("PaymentRequestTarget-\(target.id)") CustomDivider() } } +} + +struct PaymentRequestAmountView: View { + @EnvironmentObject private var contactsManager: ContactsManager + @EnvironmentObject private var currency: CurrencyViewModel + + let initialDraft: PaykitPaymentRequestDraft + let target: PaykitPaymentRequestTarget + let onContinue: (PaykitPaymentRequestDraft) -> Void + + @State private var amountViewModel = AmountInputViewModel() var body: some View { VStack(spacing: 0) { SheetHeader( - title: t("wallet__payment_request_choose_recipient"), - action: AnyView( - Button(action: onEditExpiration) { - Image("timer") - .resizable() - .scaledToFit() - .foregroundColor(.textPrimary) - .frame(width: 24, height: 24) - } - .buttonStyle(.plain) - .disabled(paymentRequests.isCreatingRequest) - .accessibilityLabel(t("wallet__payment_request_expires")) - .accessibilityIdentifier("PaymentRequestEditExpiration") - ) + title: t("wallet__payment_request_amount"), + showBackButton: true, + action: AnyView(targetAvatar) ) - ScrollView(showsIndicators: false) { - LazyVStack(alignment: .leading, spacing: 0) { - recipientSection + NumberPadTextField( + viewModel: amountViewModel, + showEditButton: false, + isFocused: true, + testIdentifier: "PaymentRequestAmountField" + ) - ForEach(recipientTargets) { target in - if let contact = contact(for: target) { - PubkyContactRow( - contact: contact, - verticalPadding: 20, - isLoading: paymentRequests.isCreatingRequest && selectedTarget == target, - isSelected: selectedTarget == target, - selectionColor: .brandAccent - ) { - selectedTarget = target - } - .accessibilityIdentifier("PaymentRequestContact-\(contact.publicKey)") - .disabled(paymentRequests.isCreatingRequest) - } - } + Spacer() + + NumberPad( + type: amountViewModel.getNumberPadType(currency: currency), + errorKey: amountViewModel.errorKey + ) { key in + amountViewModel.handleNumberPadInput(key, currency: currency) + } + + CustomButton(title: t("common__continue"), isDisabled: amountViewModel.amountSats == 0) { + onContinue( + PaykitPaymentRequestDraft( + amountSats: amountViewModel.amountSats, + note: initialDraft.note, + expiresAt: initialDraft.expiresAt + ) + ) + } + .accessibilityIdentifier("PaymentRequestAmountContinue") + } + .padding(.horizontal, 16) + .sheetBackground() + .navigationBarHidden(true) + .task { + amountViewModel.updateFromSats(initialDraft.amountSats, currency: currency) + } + } + + @ViewBuilder + private var targetAvatar: some View { + if let contact = contactsManager.contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, target.publicKey) }) { + PubkyContactAvatar(contact: contact, size: 24) + } else { + ContactAvatarLetter(source: target.publicKey, size: 24) + } + } +} + +struct PaymentRequestDetailsView: View { + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var contactsManager: ContactsManager + @EnvironmentObject private var currency: CurrencyViewModel + @Environment(PaykitPaymentRequestManager.self) private var paymentRequests + + let initialDraft: PaykitPaymentRequestDraft + let target: PaykitPaymentRequestTarget + let onEditAmount: (PaykitPaymentRequestDraft) -> Void + let onSent: (PaykitPaymentRequest) -> Void + + @State private var note = "" + @State private var expiration = PaymentRequestExpiration.week + @FocusState private var isNoteFocused: Bool + + private var contact: PubkyContact? { + contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, target.publicKey) } + } + + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: t("wallet__payment_request"), showBackButton: true) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 24) { + amount + noteInput + recipient + expirationPicker } } CustomButton( title: t("wallet__payment_request_send_request"), - isDisabled: !canSend, + icon: Image("airplane").resizable().frame(width: 16, height: 16), isLoading: paymentRequests.isCreatingRequest ) { await sendRequest() } + .buttonBottomPadding(isFocused: isNoteFocused) .accessibilityIdentifier("PaymentRequestSend") } .padding(.horizontal, 16) .sheetBackground() .navigationBarHidden(true) .interactiveDismissDisabled(paymentRequests.isCreatingRequest) - .onChange(of: allRecipientTargets) { _, targets in - if let selectedTarget, !targets.contains(selectedTarget) { - self.selectedTarget = nil + .task { + note = initialDraft.note + if initialDraft.expiresAt > Date() { + expiration = .closest(to: initialDraft.expiresAt, from: Date()) + } + } + .onChange(of: note) { _, value in + if value.count > 256 { + note = String(value.prefix(256)) + } + } + } + + private var amount: some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(currency.convert(sats: initialDraft.amountSats)?.formatted ?? "", textColor: .white64) + Button { + onEditAmount(currentDraft) + } label: { + HStack(spacing: 8) { + MoneyText( + sats: Int(clamping: initialDraft.amountSats), + unitType: .primary, + size: .display, + symbol: true, + color: .textPrimary, + symbolColor: .textSecondary + ) + Image("pencil") + .resizable() + .frame(width: 24, height: 24) + .foregroundColor(.textPrimary) + } + } + .buttonStyle(.plain) + } + } + + private var noteInput: some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("wallet__note").localizedUppercase, textColor: .white64) + NoteTextEditor( + text: $note, + placeholder: t("wallet__receive_note_placeholder"), + testIdentifier: "PaymentRequestNote", + isFocused: $isNoteFocused + ) + } + } + + private var recipient: some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("wallet__payment_request_recipient").localizedUppercase, textColor: .white64) + HStack(spacing: 16) { + if let contact { + PubkyContactAvatar(contact: contact, size: 40) + } else { + ContactAvatarLetter(source: target.publicKey, size: 40) + } + VStack(alignment: .leading, spacing: 4) { + BodyMSBText(contact?.displayName ?? PubkyPublicKeyFormat.displayTruncated(target.publicKey)) + if !note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + CaptionText(note, textColor: .white64) + .lineLimit(1) + } + } + Spacer(minLength: 8) + MoneyCell(sats: Int(clamping: initialDraft.amountSats), prefix: "") } + .padding(16) + .background(Color.gray6) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + } + + private var expirationPicker: some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("wallet__payment_request_expires").localizedUppercase, textColor: .white64) + SegmentedControl(selectedTab: $expiration, tabs: PaymentRequestExpiration.allCases) } } + private var currentDraft: PaykitPaymentRequestDraft { + PaykitPaymentRequestDraft( + amountSats: initialDraft.amountSats, + note: note.trimmingCharacters(in: .whitespacesAndNewlines), + expiresAt: expiration.date(from: Date()) + ) + } + private func sendRequest() async { - guard let selectedTarget, allRecipientTargets.contains(selectedTarget) else { return } do { - let request = try await paymentRequests.propose(draft, to: selectedTarget) + let request = try await paymentRequests.propose(currentDraft, to: target) guard paymentRequests.outgoingRequests.contains(where: { $0.id == request.id }) else { return } onSent(request) } catch { app.toast(error) } } - - private func contact(for target: PaykitPaymentRequestTarget) -> PubkyContact? { - contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, target.publicKey) } - } - - private func displayName(for target: PaykitPaymentRequestTarget) -> String { - contact(for: target)?.displayName ?? target.publicKey - } } struct PaymentRequestSentView: View { @@ -340,9 +477,6 @@ struct PaymentRequestSentView: View { PaymentRequestCard( request: request, - subtitleOverride: request.deliveryStatus == .sent - ? t("wallet__payment_request_waiting") - : t("wallet__payment_request_sending"), isHighlighted: false ) diff --git a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift index e37cb7592..f0e9f175e 100644 --- a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift +++ b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift @@ -13,6 +13,9 @@ struct PaymentRequestCard: View { var status: String? var isHighlighted = true var isActionDisabled = false + var paymentDirection: PaykitPaymentRequest.Direction? + var amountStatus: String? + var onOpen: (() -> Void)? var onPay: (() -> Void)? var onReject: (() async -> Void)? @@ -30,27 +33,28 @@ struct PaymentRequestCard: View { if let subtitleOverride { return subtitleOverride } - guard let createdAt = request.createdAt else { return senderName } - return "\(senderName) - \(Self.dateFormatter.string(from: createdAt))" + + if let note = request.note, !note.isEmpty { + return note + } + + return request.createdAt.map(Self.dateFormatter.string) ?? t("wallet__payment_request") + } + + private var title: String { + senderName } var body: some View { VStack(spacing: 0) { - HStack(spacing: 12) { - avatar - - VStack(alignment: .leading, spacing: 4) { - BodyMSBText(request.note ?? t("wallet__payment_request")) - .lineLimit(1) - CaptionText(subtitle, textColor: .white64) - .lineLimit(1) + if let onOpen { + Button(action: onOpen) { + header } - - Spacer(minLength: 8) - - MoneyCell(sats: Int(clamping: request.amountSats), prefix: "") + .buttonStyle(.plain) + } else { + header } - .padding(16) if let status { CustomDivider() @@ -63,14 +67,15 @@ struct PaymentRequestCard: View { } .padding(16) } else if onPay != nil || onReject != nil { - HStack(spacing: 12) { + HStack(spacing: 16) { CustomButton( title: t("wallet__payment_request_dismiss"), variant: .secondary, size: .small, icon: Image("x-mark").resizable().frame(width: 16, height: 16), isDisabled: isActionDisabled, - isLoading: isRejecting + isLoading: isRejecting, + shouldExpand: true ) { guard !isRejecting else { return } isRejecting = true @@ -83,7 +88,8 @@ struct PaymentRequestCard: View { title: t("common__pay"), size: .small, icon: Image("coins").resizable().frame(width: 16, height: 16), - isDisabled: isActionDisabled || isRejecting + isDisabled: isActionDisabled || isRejecting, + shouldExpand: true ) { onPay?() } @@ -101,18 +107,73 @@ struct PaymentRequestCard: View { } .shadow(color: isHighlighted ? .brandAccent.opacity(0.16) : .clear, radius: 64) .accessibilityElement(children: .contain) - .accessibilityIdentifier("PaymentRequestRow-\(request.paymentRequestId)") + .accessibilityIdentifier(rowAccessibilityIdentifier) + } + + private var header: some View { + HStack(spacing: 16) { + avatar + + VStack(alignment: .leading, spacing: 4) { + BodyMSBText(title) + .lineLimit(1) + CaptionText(subtitle, textColor: .white64) + .lineLimit(1) + } + + Spacer(minLength: 8) + + if let amountStatus { + VStack(alignment: .trailing, spacing: 2) { + MoneyText( + sats: Int(clamping: request.amountSats), + unitType: .primary, + size: .bodyMSB, + prefix: amountPrefix, + color: .textPrimary, + symbolColor: .textSecondary + ) + CaptionText(amountStatus, textColor: .white64) + } + } else { + MoneyCell(sats: Int(clamping: request.amountSats), prefix: amountPrefix) + } + } + .padding(16) + .contentShape(Rectangle()) } @ViewBuilder private var avatar: some View { - if let contact { + if let paymentDirection { + CircularIcon( + icon: paymentDirection == .incoming ? "arrow-up" : "arrow-down", + iconColor: .brandAccent, + backgroundColor: .brand16, + size: 40 + ) + } else if let contact { PubkyContactAvatar(contact: contact, size: 40) } else { ContactAvatarLetter(source: request.counterparty, size: 40) } } + private var amountPrefix: String { + switch paymentDirection { + case .incoming: "-" + case .outgoing: "+" + case nil: "" + } + } + + private var rowAccessibilityIdentifier: String { + let period = request.billingPeriod.map { + PaykitSubscriptionTimestamp.string(from: $0.startsAt) + } ?? "one-time" + return "PaymentRequestRow-\(request.paymentRequestId)-\(request.counterparty)-\(request.counterpartyReceiverPath)-\(period)" + } + private static let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = .autoupdatingCurrent @@ -146,7 +207,7 @@ struct PaymentRequestsSheet: View { ScrollView(showsIndicators: false) { LazyVStack(spacing: 16) { - ForEach(paymentRequests.pendingRequests.prefix(3)) { request in + ForEach(paymentRequests.pendingRequests.sorted(by: Self.newestFirst).prefix(3)) { request in requestCard(request) } } @@ -161,7 +222,7 @@ struct PaymentRequestsSheet: View { CustomButton(title: t("wallet__payment_requests_see_all")) { sheets.hideSheet(reason: "Opening all payment requests") - navigation.navigate(.paymentRequests) + navigation.navigate(.subscriptions(showPayments: true)) } .accessibilityIdentifier("PaymentRequestsSeeAll") } @@ -179,24 +240,32 @@ struct PaymentRequestsSheet: View { private func requestCard(_ request: PaykitPaymentRequest) -> some View { PaymentRequestCard( request: request, + onOpen: { + sheets.hideSheet(reason: "Opening payment request details") + navigation.navigate(.paymentRequestDetail(request.id)) + }, onPay: { sheets.hideSheetBeforePerforming(reason: "Paying payment request") { _ = paymentRequests.requestPresentation(request) } }, onReject: { - await reject(request) + await dismiss(request) } ) } - private func reject(_ request: PaykitPaymentRequest) async { + private func dismiss(_ request: PaykitPaymentRequest) async { do { - try await paymentRequests.reject(request) + try await paymentRequests.dismiss(request) } catch { app.toast(error) } } + + private static func newestFirst(_ lhs: PaykitPaymentRequest, _ rhs: PaykitPaymentRequest) -> Bool { + (lhs.createdAt ?? .distantPast) > (rhs.createdAt ?? .distantPast) + } } struct PaymentRequestsView: View { @@ -210,15 +279,13 @@ struct PaymentRequestsView: View { } @EnvironmentObject private var app: AppViewModel - @EnvironmentObject private var contactsManager: ContactsManager + @EnvironmentObject private var navigation: NavigationViewModel @EnvironmentObject private var sheets: SheetViewModel @Environment(PaykitPaymentRequestManager.self) private var paymentRequests var body: some View { VStack(spacing: 0) { - NavigationBar(title: t("wallet__payment_requests")) - - if paymentRequests.historyRequests.isEmpty { + if activeRequests.isEmpty, paymentRequests.historyRequests.isEmpty { emptyState } else { ScrollView(showsIndicators: false) { @@ -234,7 +301,13 @@ struct PaymentRequestsView: View { CaptionMText(section.title.localizedUppercase, textColor: .white64) .padding(.top, 8) ForEach(section.requests) { request in - PaymentRequestCard(request: request, subtitleOverride: historyDate(for: request), isHighlighted: false) + PaymentRequestCard( + request: request, + subtitleOverride: historyDate(for: request), + isHighlighted: false, + paymentDirection: request.lifecycleState == .proofSubmitted ? request.direction : nil, + onOpen: { navigation.navigate(.paymentRequestDetail(request.id)) } + ) } } } @@ -244,21 +317,23 @@ struct PaymentRequestsView: View { } if !paymentRequests.eligibleTargets.isEmpty { - CustomButton(title: t("wallet__payment_request_request_payment")) { - let draft = PaykitPaymentRequestDraft(amountSats: 0, note: "", expiresAt: .now) - sheets.showSheet(.receive, data: ReceiveConfig(view: .paymentRequestDetails(draft))) + CustomButton( + title: activeRequests.isEmpty && paymentRequests.historyRequests.isEmpty + ? t("wallet__payment_request_request") + : t("wallet__payment_request_request_payment") + ) { + sheets.showSheet( + .receive, + data: ReceiveConfig(view: .paymentRequestRecipient(ReceiveSheet.defaultPaymentRequestDraft)) + ) } .padding(.bottom, 16) .accessibilityIdentifier("PaymentRequestRequestPayment") } } - .padding(.horizontal, 16) .background(Color.black) .navigationBarHidden(true) .accessibilityIdentifier("PaymentRequestsScreen") - .task { - await paymentRequests.refresh() - } } private var emptyState: some View { @@ -283,9 +358,16 @@ struct PaymentRequestsView: View { } private var activeRequests: [PaykitPaymentRequest] { - paymentRequests.historyRequests.filter { - $0.lifecycleState == .proposed && !$0.isExpired(at: Date()) + let pending = paymentRequests.pendingRequests + let historical = paymentRequests.historyRequests.filter { + ($0.lifecycleState == .proposed && !$0.isExpired(at: Date())) + || ($0.direction == .outgoing && $0.lifecycleState == .accepted) } + return (pending + historical).reduce(into: [PaykitPaymentRequest]()) { requests, request in + if !requests.contains(where: { $0.id == request.id }) { + requests.append(request) + } + }.sorted { ($0.createdAt ?? .distantPast) > ($1.createdAt ?? .distantPast) } } private var historicalRequests: [PaykitPaymentRequest] { @@ -327,13 +409,13 @@ struct PaymentRequestsView: View { case .proposalExpired: return t("wallet__payment_request_status_expired") case .accepted: - return t("wallet__payment_request_status_accepted") + return t("wallet__payment_request_status_pending") case .rejected: return t("wallet__payment_request_status_rejected") case .canceled: return t("wallet__payment_request_status_canceled") case .proofSubmitted: - return t("wallet__payment_request_status_proof_submitted") + return t("wallet__payment_request_status_paid") case .recoveryRequired: return t("wallet__payment_request_status_action_required") case .invalidConflict, .activeRecurring, .unknown: @@ -346,51 +428,32 @@ struct PaymentRequestsView: View { if isActionable(request) { PaymentRequestCard( request: request, - subtitleOverride: activeDate(for: request), isActionDisabled: paymentRequests.requestedPresentationId == request.id, + onOpen: { navigation.navigate(.paymentRequestDetail(request.id)) }, onPay: { _ = paymentRequests.requestPresentation(request) }, - onReject: { await reject(request) } + onReject: { await dismiss(request) } ) } else if request.direction == .outgoing { PaymentRequestCard( request: request, - subtitleOverride: t( - "wallet__payment_request_waiting_for_recipient", - variables: ["name": displayName(for: request)] - ), - isHighlighted: false + isHighlighted: false, + amountStatus: t("wallet__payment_request_status_pending").localizedLowercase, + onOpen: { navigation.navigate(.paymentRequestDetail(request.id)) } ) } else { - PaymentRequestCard(request: request, status: status(for: request)) + PaymentRequestCard( + request: request, + status: status(for: request), + onOpen: { navigation.navigate(.paymentRequestDetail(request.id)) } + ) } } - private func activeDate(for request: PaykitPaymentRequest) -> String { - guard let createdAt = request.createdAt else { return status(for: request) } - return Self.dateTimeFormatter.string(from: createdAt) - } - private func historyDate(for request: PaykitPaymentRequest) -> String { guard let createdAt = request.createdAt else { return status(for: request) } return Self.dateFormatter.string(from: createdAt) } - private func displayName(for request: PaykitPaymentRequest) -> String { - guard let contact = contactsManager.contacts.first(where: { - PubkyPublicKeyFormat.matches($0.publicKey, request.counterparty) - }) else { - return PubkyPublicKeyFormat.displayTruncated(request.counterparty) - } - return contact.displayName - } - - private static let dateTimeFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.locale = .autoupdatingCurrent - formatter.setLocalizedDateFormatFromTemplate("MMMMdHm") - return formatter - }() - private static let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = .autoupdatingCurrent @@ -398,11 +461,205 @@ struct PaymentRequestsView: View { return formatter }() - private func reject(_ request: PaykitPaymentRequest) async { + private func dismiss(_ request: PaykitPaymentRequest) async { do { - try await paymentRequests.reject(request) + try await paymentRequests.dismiss(request) } catch { app.toast(error) } } } + +struct PaymentRequestDetailView: View { + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var contactsManager: ContactsManager + @EnvironmentObject private var currency: CurrencyViewModel + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var tagManager: TagManager + @Environment(PaykitPaymentRequestManager.self) private var paymentRequests + + let id: PaykitPaymentRequest.ID + @State private var showAddTagSheet = false + + private var request: PaykitPaymentRequest? { + (paymentRequests.pendingRequests + paymentRequests.historyRequests).first { $0.id == id } + } + + private var contact: PubkyContact? { + guard let request else { return nil } + return contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, request.counterparty) } + } + + var body: some View { + VStack(spacing: 0) { + NavigationBar(title: t("wallet__payment_request")) + + if let request { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 32) { + amount(request) + details(request) + counterparty(request) + if isActionable(request) { + tags + } + + if let note = request.note, !note.isEmpty { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("wallet__payment_request_note").localizedUppercase, textColor: .white64) + VStack(alignment: .leading, spacing: 0) { + ZigzagDivider() + TitleText(note) + .padding(24) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white10) + } + } + } + } + .padding(.top, 24) + .padding(.bottom, 120) + } + + if isActionable(request) { + actions(request) + } + } else { + Spacer() + BodyMText(t("wallet__payment_request_status_unavailable"), textColor: .white64) + Spacer() + } + } + .padding(.horizontal, 16) + .background(Color.black) + .navigationBarHidden(true) + .accessibilityIdentifier("PaymentRequestDetailScreen") + .task { + tagManager.clearSelectedTags() + } + .sheet(isPresented: $showAddTagSheet) { + AddProfileTagSheet { tag in + tagManager.addTagToSelection(tag) + } + } + } + + private func amount(_ request: PaykitPaymentRequest) -> some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(currency.convert(sats: request.amountSats)?.formatted ?? "", textColor: .white64) + HStack(spacing: 16) { + MoneyText( + sats: Int(clamping: request.amountSats), + unitType: .primary, + size: .display, + symbol: false, + prefix: request.direction == .incoming ? "-" : "", + color: .textPrimary, + symbolColor: .textSecondary + ) + Spacer() + CircularIcon( + icon: request.direction == .incoming ? "arrow-down" : "arrow-up", + iconColor: request.direction == .incoming ? .purpleAccent : .brandAccent, + backgroundColor: request.direction == .incoming ? .purple16 : .brand16, + size: 48 + ) + } + } + } + + private func details(_ request: PaykitPaymentRequest) -> some View { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) { + LabeledDetailCell( + title: t("wallet__payment_request_date"), + value: request.createdAt.map(Self.dateFormatter.string) ?? "–", + icon: "calendar" + ) + LabeledDetailCell( + title: t("wallet__payment_request_time"), + value: request.createdAt.map(Self.timeFormatter.string) ?? "–", + icon: "clock" + ) + } + } + + private func counterparty(_ request: PaykitPaymentRequest) -> some View { + VStack(alignment: .leading, spacing: 12) { + CaptionMText(t("wallet__payment_request_contact").localizedUppercase, textColor: .white64) + HStack(spacing: 16) { + if let contact { + PubkyContactAvatar(contact: contact, size: 48) + } else { + ContactAvatarLetter(source: request.counterparty, size: 48) + } + BodyMSBText(contact?.displayName ?? PubkyPublicKeyFormat.displayTruncated(request.counterparty)) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.gray6) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .frame(maxWidth: .infinity, alignment: .leading) + CustomDivider() + } + } + + private var tags: some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("wallet__tags").localizedUppercase, textColor: .white64) + TagsListView( + tags: tagManager.selectedTagsArray, + icon: .close, + onAddTag: { showAddTagSheet = true }, + onTagDelete: tagManager.removeTagFromSelection, + addButtonTestId: "PaymentRequestAddTag" + ) + .padding(.bottom, 16) + CustomDivider() + } + } + + private func actions(_ request: PaykitPaymentRequest) -> some View { + HStack(spacing: 16) { + CustomButton( + title: t("wallet__payment_request_dismiss"), + variant: .secondary, + icon: Image("x-mark").resizable().frame(width: 16, height: 16) + ) { + do { + try await paymentRequests.dismiss(request) + navigation.navigateBack() + } catch { + app.toast(error) + } + } + + CustomButton( + title: t("common__pay"), + icon: Image("coins").resizable().frame(width: 16, height: 16) + ) { + guard paymentRequests.requestPresentation(request) else { return } + tagManager.preserveSelectedTags(for: request.id) + navigation.navigateBack() + } + } + .padding(.bottom, 16) + } + + private func isActionable(_ request: PaykitPaymentRequest) -> Bool { + paymentRequests.pendingRequests.contains { $0.id == request.id } + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .autoupdatingCurrent + formatter.setLocalizedDateFormatFromTemplate("MMMMd") + return formatter + }() + + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .autoupdatingCurrent + formatter.setLocalizedDateFormatFromTemplate("Hm") + return formatter + }() +} diff --git a/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift new file mode 100644 index 000000000..2fc83aadf --- /dev/null +++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift @@ -0,0 +1,870 @@ +import Lottie +import SwiftUI + +struct SubscriptionSheetItem: SheetItem { + enum Route: Hashable { + case review(PaykitSubscription) + case success + case details(PaykitSubscription) + case cancel(PaykitSubscription) + } + + let route: Route + let id: SheetID = .subscription + let size: SheetSize = .large +} + +struct SubscriptionsView: View { + private enum Tab: String, CustomStringConvertible { + case overview + case payments + + var description: String { + switch self { + case .overview: t("subscriptions__overview") + case .payments: t("subscriptions__payments") + } + } + } + + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var sheets: SheetViewModel + @Environment(PaykitPaymentRequestManager.self) private var paymentRequests + + @State private var selectedTab = Tab.overview + @State private var now = Date() + private let showPayments: Bool + + init(showPayments: Bool = false) { + self.showPayments = showPayments + _selectedTab = State(initialValue: showPayments ? .payments : .overview) + } + + private var proposals: [PaykitSubscription] { + paymentRequests.subscriptions.filter { $0.isProposalVisible(at: now) } + } + + private var active: [PaykitSubscription] { + paymentRequests.subscriptions.filter { $0.isActive(at: now) } + } + + private var expired: [PaykitSubscription] { + paymentRequests.subscriptions.filter { + $0.isExpired(at: now) && $0.wasAccepted + } + } + + private var hasVisibleSubscriptions: Bool { + !proposals.isEmpty || !active.isEmpty || !expired.isEmpty + } + + var body: some View { + VStack(spacing: 0) { + NavigationBar(title: t("subscriptions__title")) + SegmentedControl( + selectedTab: $selectedTab, + tabItems: [ + TabItem(.overview), + TabItem(.payments, badge: paymentRequests.pendingRequests.count), + ] + ) + + if selectedTab == .payments { + PaymentRequestsView() + } else if !hasVisibleSubscriptions { + emptyState + } else { + ScrollView(showsIndicators: false) { + LazyVStack(alignment: .leading, spacing: 32) { + metrics + section(t("subscriptions__proposals"), subscriptions: proposals) + section(t("subscriptions__active"), subscriptions: active) + section(t("subscriptions__expired"), subscriptions: expired) + } + .padding(.top, 32) + .padding(.bottom, 120) + } + } + } + .padding(.horizontal, 16) + .background(Color.black) + .navigationBarHidden(true) + .accessibilityIdentifier("SubscriptionsScreen") + .task { + await paymentRequests.refresh() + } + .onChange(of: showPayments, initial: true) { _, showPayments in + selectedTab = showPayments ? .payments : .overview + } + .task(id: nextTransitionDate) { + guard let nextTransitionDate else { return } + do { + try await Task.sleep(for: .seconds(max(0, nextTransitionDate.timeIntervalSince(now)))) + } catch { + return + } + now = Date() + } + } + + private var nextTransitionDate: Date? { + subscriptionNextTransitionDate(subscriptions: paymentRequests.subscriptions, now: now) + } + + private var emptyState: some View { + VStack(alignment: .leading, spacing: 0) { + Spacer() + + Image("subscription-clock") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 256, height: 256) + .frame(maxWidth: .infinity) + .accessibilityHidden(true) + + Spacer() + + DisplayText(t("subscriptions__empty_headline"), accentColor: .purpleAccent) + Spacer().frame(height: 12) + BodyMText(t("subscriptions__empty_description"), textColor: .white64) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.bottom, 24) + } + + private var metrics: some View { + HStack(spacing: 16) { + SubscriptionMetric(title: t("subscriptions__due_this_month"), icon: "calendar") { + MoneyText( + sats: dueThisMonthSats, + unitType: .primary, + size: .bodyMSB, + prefix: "", + color: .textPrimary, + symbolColor: .textSecondary + ) + } + Rectangle() + .fill(Color.white16) + .frame(width: 1, height: 50) + SubscriptionMetric(title: t("subscriptions__active"), icon: "arrows-clockwise") { + BodyMSBText("\(active.count)") + } + } + } + + @ViewBuilder + private func section(_ title: String, subscriptions: [PaykitSubscription]) -> some View { + if !subscriptions.isEmpty { + VStack(alignment: .leading, spacing: 16) { + CaptionMText(title.localizedUppercase, textColor: .white64) + ForEach(subscriptions) { subscription in + Button { + if subscription.isProposalVisible(at: now) { + paymentRequests.requestSubscriptionPresentation(subscription) + sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .review(subscription))) + } else { + navigation.navigate(.subscriptionDetail(subscription.id)) + } + } label: { + SubscriptionRow(subscription: subscription, now: now) + } + .buttonStyle(.plain) + } + } + } + } + + private var dueThisMonthSats: Int { + var calendar = Calendar.autoupdatingCurrent + calendar.timeZone = .autoupdatingCurrent + guard let monthInterval = calendar.dateInterval(of: .month, for: now) else { return 0 } + let paidRequestIds = Set(paymentRequests.historyRequests.lazy + .filter { $0.lifecycleState == .proofSubmitted } + .map(\.id)) + + return paymentRequests.subscriptions + .filter { $0.lifecycleState == .activeRecurring } + .reduce(into: 0) { total, subscription in + guard let acceptedAt = paymentRequests.acceptedAt(for: subscription) else { return } + let dueCount = subscription.recurrence.periods(through: monthInterval.end, acceptedAt: acceptedAt).filter { period in + let requestId = PaykitPaymentRequest.ID( + paymentRequestId: subscription.paymentRequestId, + counterparty: subscription.counterparty, + counterpartyReceiverPath: subscription.counterpartyReceiverPath, + billingPeriodStartsAt: period.startsAt + ) + return period.startsAt >= monthInterval.start && period.startsAt < monthInterval.end + && !subscription.paidPeriods.contains(period) + && !paidRequestIds.contains(requestId) + }.count + let (subtotal, didOverflow) = Int(clamping: subscription.amountSats).multipliedReportingOverflow(by: dueCount) + guard !didOverflow else { + total = .max + return + } + let (sum, sumDidOverflow) = total.addingReportingOverflow(subtotal) + total = sumDidOverflow ? .max : sum + } + } +} + +func subscriptionNextTransitionDate( + subscriptions: [PaykitSubscription], + now: Date, + calendar: Calendar = .autoupdatingCurrent +) -> Date? { + let activeSubscriptions = subscriptions.filter { $0.isActive(at: now) } + var dates = subscriptions.flatMap { + [$0.recurrence.startsAt, $0.proposalExpiresAt, $0.recurrence.endsAt].compactMap { $0 } + } + dates += activeSubscriptions.compactMap { $0.recurrence.nextPeriod(after: now)?.startsAt } + if !activeSubscriptions.isEmpty, let nextMonth = calendar.dateInterval(of: .month, for: now)?.end { + dates.append(nextMonth) + } + return dates.filter { $0 > now }.min() +} + +private struct SubscriptionMetric: View { + let title: String + let icon: String + @ViewBuilder let content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + CaptionMText(title.localizedUppercase, textColor: .white64) + HStack(spacing: 8) { + Image(icon) + .resizable() + .foregroundColor(.purpleAccent) + .frame(width: 24, height: 24) + content() + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct SubscriptionRow: View { + let subscription: PaykitSubscription + let now: Date + + var body: some View { + HStack(spacing: 16) { + SubscriptionAvatar(subscription: subscription, size: 40) + + VStack(alignment: .leading, spacing: 4) { + BodyMSBText(subscription.note ?? t("subscriptions__subscription")) + .lineLimit(1) + CaptionText(subscription.rowSubtitle(at: now), textColor: .white64) + .lineLimit(1) + } + + Spacer(minLength: 8) + + MoneyCell(sats: Int(clamping: subscription.amountSats), prefix: "") + } + .padding(16) + .background(Color.gray6) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .opacity(subscription.isExpired(at: now) ? 0.5 : 1) + .contentShape(Rectangle()) + .accessibilityIdentifier( + "SubscriptionRow-\(subscription.paymentRequestId)-\(subscription.counterparty)-\(subscription.counterpartyReceiverPath)" + ) + } +} + +struct SubscriptionAvatar: View { + @EnvironmentObject private var contactsManager: ContactsManager + + let subscription: PaykitSubscription + let size: CGFloat + + private var contact: PubkyContact? { + contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, subscription.counterparty) } + } + + var body: some View { + if let contact { + PubkyContactAvatar(contact: contact, size: size) + } else { + ContactAvatarLetter(source: subscription.counterparty, size: size) + } + } +} + +struct SubscriptionDetailView: View { + @EnvironmentObject private var sheets: SheetViewModel + @Environment(PaykitPaymentRequestManager.self) private var paymentRequests + + let id: PaykitSubscription.ID + @State private var now = Date() + + private var subscription: PaykitSubscription? { + paymentRequests.subscriptions.first { $0.id == id } + } + + var body: some View { + VStack(spacing: 0) { + NavigationBar(title: subscription?.note ?? t("subscriptions__subscription")) + + if let subscription { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 32) { + VStack(alignment: .leading, spacing: 16) { + CaptionMText(subscription.recurrence.cadenceLabel.localizedUppercase, textColor: .white64) + HStack(spacing: 16) { + MoneyText( + sats: Int(clamping: subscription.amountSats), + unitType: .primary, + size: .display, + symbol: true, + color: .textPrimary, + symbolColor: .textSecondary + ) + Spacer() + SubscriptionAvatar(subscription: subscription, size: 48) + } + } + + details(subscription) + payments(subscription) + } + .padding(.top, 24) + .padding(.bottom, 120) + .opacity(subscription.isExpired(at: now) ? 0.5 : 1) + } + + footer(subscription) + } else { + Spacer() + BodyMText(t("subscriptions__unavailable"), textColor: .white64) + Spacer() + } + } + .padding(.horizontal, 16) + .background(Color.black) + .navigationBarHidden(true) + .task(id: nextTransitionDate) { + guard let nextTransitionDate else { return } + do { + try await Task.sleep(for: .seconds(max(0, nextTransitionDate.timeIntervalSince(now)))) + } catch { + return + } + now = Date() + } + } + + private func details(_ subscription: PaykitSubscription) -> some View { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) { + LabeledDetailCell( + title: t("subscriptions__subscription"), + value: subscription.note ?? t("subscriptions__subscription"), + icon: "cube" + ) + LabeledDetailCell(title: t("subscriptions__frequency"), value: subscription.recurrence.frequencyValue, icon: "arrows-clockwise") + LabeledDetailCell( + title: t("subscriptions__status"), + value: subscription.isActive(at: now) ? t("subscriptions__active") : t("subscriptions__expired"), + icon: "check-mark" + ) + if subscription.isActive(at: now) || subscription.recurrence.endsAt != nil { + LabeledDetailCell( + title: timingTitle(subscription), + value: renewalText(subscription), + icon: "calendar" + ) + } + } + } + + @ViewBuilder + private func footer(_ subscription: PaykitSubscription) -> some View { + let hasMoreInfo = subscription.metadata.description != nil || !subscription.metadata.benefits.isEmpty + let canCancel = subscription.isActive(at: now) && subscription.recurrence.endsAt == nil + if hasMoreInfo || canCancel { + HStack(spacing: 16) { + if hasMoreInfo { + CustomButton(title: t("subscriptions__more_info"), variant: .secondary) { + sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .details(subscription))) + } + } + if canCancel { + CustomButton( + title: t("subscriptions__cancel"), + icon: Image("x-mark").resizable().frame(width: 16, height: 16) + ) { + sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .cancel(subscription))) + } + } + } + .padding(.bottom, 16) + } + } + + @ViewBuilder + private func payments(_ subscription: PaykitSubscription) -> some View { + let payments = paymentRequests.historyRequests.filter { + $0.belongs(to: subscription) + } + if !payments.isEmpty { + VStack(alignment: .leading, spacing: 12) { + CaptionMText(t("subscriptions__payments").localizedUppercase, textColor: .white64) + ForEach(payments) { payment in + PaymentRequestCard( + request: payment, + subtitleOverride: payment.createdAt.map(Self.dateFormatter.string), + isHighlighted: false, + paymentDirection: .incoming + ) + } + } + } + } + + private func renewalText(_ subscription: PaykitSubscription) -> String { + guard subscription.isActive(at: now) else { + return subscription.recurrence.endsAt.map(Self.dateFormatter.string) ?? t("subscriptions__expired") + } + let date = subscription.recurrence.endsAt ?? subscription.recurrence.nextPeriod(after: now)?.startsAt + return date.map(Self.dateFormatter.string) ?? t("subscriptions__ongoing") + } + + private func timingTitle(_ subscription: PaykitSubscription) -> String { + guard subscription.isActive(at: now) else { return t("subscriptions__expired") } + return subscription.recurrence.endsAt == nil ? t("subscriptions__renews") : t("subscriptions__expires") + } + + private var nextTransitionDate: Date? { + guard let subscription else { return nil } + return [ + subscription.recurrence.startsAt, + subscription.recurrence.endsAt, + subscription.isActive(at: now) ? subscription.recurrence.nextPeriod(after: now)?.startsAt : nil, + ] + .compactMap { $0 } + .filter { $0 > now } + .min() + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .autoupdatingCurrent + formatter.setLocalizedDateFormatFromTemplate("MMMMdyyyy") + return formatter + }() +} + +struct SubscriptionSheet: View { + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var sheets: SheetViewModel + @Environment(PaykitPaymentRequestManager.self) private var paymentRequests + + let config: SubscriptionSheetItem + + @State private var route: SubscriptionSheetItem.Route + @State private var previousRoute: SubscriptionSheetItem.Route? + @State private var now = Date() + + init(config: SubscriptionSheetItem) { + self.config = config + _route = State(initialValue: config.route) + _previousRoute = State(initialValue: nil) + } + + var body: some View { + Sheet(id: .subscription, data: config) { + switch route { + case let .review(subscription): + review(subscription) + case .success: + success() + case let .details(subscription): + moreInfo(subscription) + case let .cancel(subscription): + cancel(subscription) + } + } + .task { + if case let .review(subscription) = route { + now = Date() + paymentRequests.markSubscriptionProposalPresented(subscription) + } + } + .onChange(of: route) { _, route in + guard case let .review(subscription) = route else { return } + now = Date() + paymentRequests.markSubscriptionProposalPresented(subscription) + } + .onChange(of: paymentRequests.subscriptions) { + guard !paymentRequests.isProcessingSubscription, + case let .review(subscription) = route, + !paymentRequests.subscriptions.contains(where: { + $0.id == subscription.id && $0.isProposalVisible(at: Date()) + }) + else { return } + sheets.hideSheetIfActive(.subscription, reason: "Subscription proposal is no longer available") + } + .task(id: reviewTransitionDate) { + guard let reviewTransitionDate else { return } + do { + try await Task.sleep(for: .seconds(max(0, reviewTransitionDate.timeIntervalSinceNow))) + } catch { + return + } + now = Date() + } + } + + private func review(_ subscription: PaykitSubscription) -> some View { + let payOnAcceptance = subscription.paymentDueOnAcceptance(at: now) != nil + return VStack(spacing: 0) { + SheetHeader(title: t("subscriptions__review_and_subscribe")) + SubscriptionAmountHeader(subscription: subscription) + SubscriptionProviderCard(subscription: subscription) { + previousRoute = route + route = .details(subscription) + } + + if !subscription.recurrence.unit.isSupported { + BodyMText(t("subscriptions__unsupported_description"), textColor: .white64) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 16) + } else if subscription.acceptedPaymentEndpointIdentifiers.isEmpty { + BodyMText(t("subscriptions__unsupported_payment_description"), textColor: .white64) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 16) + } + + Spacer() + Image("subscription-clock") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 256, height: 256) + .accessibilityHidden(true) + Spacer() + + if subscription.isProposalActionable(at: now) { + SwipeButton( + title: payOnAcceptance + ? t("subscriptions__swipe_to_subscribe_and_pay") + : t("subscriptions__swipe_to_subscribe"), + accentColor: .purpleAccent, + isLoading: paymentRequests.isProcessingSubscription + ) { + do { + try await accept(subscription) + } catch { + app.toast(error) + throw error + } + } + } + } + .padding(.horizontal, 16) + } + + private func success() -> some View { + SubscriptionSuccessView { + sheets.hideSheet(reason: "Subscription success closed") + } + } + + @MainActor + private func accept(_ subscription: PaykitSubscription) async throws { + guard let dueRequest = try await paymentRequests.accept(subscription) else { + guard paymentRequests.subscriptions.contains(where: { + $0.id == subscription.id && $0.lifecycleState == .activeRecurring + }) else { + throw PaykitPaymentRequestError.requestUnavailable + } + route = .success + return + } + + let resolution: PublicPaykitPaymentLaunchResult + do { + resolution = try await PrivatePaykitService.shared.beginPaymentRequestWaitingForUpdatedList(dueRequest) + } catch { + try showInitialPaymentFailure(dueRequest, error: error) + return + } + guard case let .opened(paymentTarget, privatePaymentContext) = resolution else { + try showInitialPaymentFailure(dueRequest, error: PaykitPaymentRequestError.requestUnavailable) + return + } + + let context = ContactPaymentContext( + publicKey: dueRequest.counterparty, + privatePaymentContext: privatePaymentContext, + incomingPaymentRequest: dueRequest, + isInitialSubscriptionPayment: true + ) + guard app.claimContactPaymentContext(context) else { + throw PaykitPaymentRequestError.operationInProgress + } + + do { + try await app.handleScannedData(paymentTarget, claimedContactPaymentContext: context) + } catch { + let failure = SendFailureContext( + error: error, + retryRoute: .confirm, + contactPaymentContext: context + ) + sheets.showSheet(.send, data: SendConfig(view: .failure(failure))) + return + } + + let sendRoute: SendRoute = app.lnurlPayData == nil ? .confirm : .lnurlPayConfirm + sheets.showSheet(.send, data: SendConfig(view: sendRoute)) + } + + @MainActor + private func showInitialPaymentFailure(_ request: PaykitPaymentRequest, error: Error) throws { + let context = ContactPaymentContext( + publicKey: request.counterparty, + incomingPaymentRequest: request, + isInitialSubscriptionPayment: true + ) + guard app.claimContactPaymentContext(context) else { + throw PaykitPaymentRequestError.operationInProgress + } + let failure = SendFailureContext( + error: error, + retryRoute: .confirm, + contactPaymentContext: context + ) + sheets.showSheet(.send, data: SendConfig(view: .failure(failure))) + } + + private var reviewTransitionDate: Date? { + guard case let .review(subscription) = route else { return nil } + return [subscription.recurrence.startsAt, subscription.proposalExpiresAt, subscription.recurrence.endsAt] + .compactMap { $0 } + .filter { $0 > now } + .min() + } + + private func moreInfo(_ subscription: PaykitSubscription) -> some View { + VStack(spacing: 0) { + SheetHeader(title: t("subscriptions__details"), showBackButton: true) { + if let previousRoute { + route = previousRoute + self.previousRoute = nil + } else { + sheets.hideSheet(reason: "Subscription details closed") + } + } + SubscriptionProviderCard(subscription: subscription) + .padding(.bottom, 24) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 16) { + if let description = subscription.metadata.description { + BodySSBText(description) + } + ForEach(subscription.metadata.benefits.indices, id: \.self) { index in + HStack(alignment: .top, spacing: 12) { + BodySSBText("•") + BodySSBText(subscription.metadata.benefits[index]) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + CustomButton(title: t("common__ok")) { + sheets.hideSheet(reason: "Subscription details closed") + } + } + .padding(.horizontal, 16) + } + + private func cancel(_ subscription: PaykitSubscription) -> some View { + VStack(spacing: 0) { + SheetHeader(title: t("subscriptions__cancel_subscription")) + SubscriptionAmountHeader(subscription: subscription) + SubscriptionProviderCard(subscription: subscription, subtitle: subscription.rowSubtitle(at: now)) { + previousRoute = route + route = .details(subscription) + } + + Spacer() + Image("cross") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 256, height: 256) + .accessibilityHidden(true) + Spacer() + + SwipeButton( + title: t("subscriptions__swipe_to_cancel"), + accentColor: .redAccent, + isLoading: paymentRequests.isProcessingSubscription + ) { + do { + try await paymentRequests.cancel(subscription) + sheets.hideSheet(reason: "Subscription canceled") + } catch { + app.toast(error) + throw error + } + } + } + .padding(.horizontal, 16) + } +} + +struct SubscriptionSuccessView: View { + let onClose: () -> Void + + private static let confettiAnimation: LottieAnimation? = { + guard let url = Bundle.main.url(forResource: "confetti-purple", withExtension: "json") else { return nil } + return LottieAnimation.filepath(url.path) + }() + + var body: some View { + ZStack { + if let animation = Self.confettiAnimation { + LottieView(animation: animation) + .playing(loopMode: .loop) + .scaleEffect(1.9) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityHidden(true) + } + + VStack(spacing: 0) { + SheetHeader(title: t("subscriptions__subscribed")) + Spacer() + Image("check") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 256, height: 256) + .accessibilityHidden(true) + Spacer() + CustomButton(title: t("common__close"), action: onClose) + } + .padding(.horizontal, 16) + } + } +} + +private struct SubscriptionAmountHeader: View { + let subscription: PaykitSubscription + + var body: some View { + MoneyStack(sats: Int(clamping: subscription.amountSats), showSymbol: true) + .padding(.bottom, 20) + } +} + +private struct SubscriptionProviderCard: View { + let subscription: PaykitSubscription + var subtitle: String? + var action: (() -> Void)? + + var body: some View { + if let action { + Button(action: action) { + content(showsChevron: true) + } + .buttonStyle(.plain) + } else { + content(showsChevron: false) + } + } + + private func content(showsChevron: Bool) -> some View { + HStack(spacing: 16) { + SubscriptionAvatar(subscription: subscription, size: 40) + VStack(alignment: .leading, spacing: 4) { + BodyMSBText(subscription.note ?? t("subscriptions__subscription")) + .lineLimit(1) + CaptionText(subtitle ?? subscription.recurrence.subscriptionFrequencyLabel, textColor: .white64) + .lineLimit(1) + } + Spacer() + if showsChevron { + Image("chevron") + .resizable() + .foregroundColor(.white64) + .frame(width: 24, height: 24) + } + } + .padding(showsChevron ? 16 : 0) + .background(showsChevron ? Color.gray6 : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: showsChevron ? 16 : 0)) + } +} + +extension PaykitSubscriptionRecurrence { + var cadenceLabel: String { + let singular = every == 1 + switch unit { + case .day: + return singular ? t("subscriptions__per_day") : t("subscriptions__every_days", variables: ["count": "\(every)"]) + case .week: + return singular ? t("subscriptions__per_week") : t("subscriptions__every_weeks", variables: ["count": "\(every)"]) + case .month: + return singular ? t("subscriptions__per_month") : t("subscriptions__every_months", variables: ["count": "\(every)"]) + case .year: + return singular ? t("subscriptions__per_year") : t("subscriptions__every_years", variables: ["count": "\(every)"]) + case .minute, .hour: + return t("subscriptions__unsupported_frequency") + } + } + + var frequencyValue: String { + guard every == 1 else { return cadenceLabel } + switch unit { + case .day: + return t("subscriptions__daily") + case .week: + return t("subscriptions__weekly") + case .month: + return t("subscriptions__monthly") + case .year: + return t("subscriptions__yearly") + case .minute, .hour: + return t("subscriptions__unsupported_frequency") + } + } + + var subscriptionFrequencyLabel: String { + guard every == 1 else { return cadenceLabel } + switch unit { + case .day: + return t("subscriptions__daily_subscription") + case .week: + return t("subscriptions__weekly_subscription") + case .month: + return t("subscriptions__monthly_subscription") + case .year: + return t("subscriptions__yearly_subscription") + case .minute, .hour: + return t("subscriptions__unsupported_frequency") + } + } +} + +private extension PaykitSubscription { + func rowSubtitle(at now: Date) -> String { + if isProposalVisible(at: now) || !recurrence.unit.isSupported { + return recurrence.subscriptionFrequencyLabel + } + if isExpired(at: now) { + guard let endsAt = recurrence.endsAt else { return t("subscriptions__expired") } + return t("subscriptions__expires_date", variables: ["date": endsAt.formatted(.dateTime.month(.wide).day())]) + } + if let endsAt = recurrence.endsAt { + return t("subscriptions__expires_date", variables: ["date": endsAt.formatted(.dateTime.month(.wide).day())]) + } + guard let renewal = recurrence.nextPeriod(after: now)?.startsAt else { + return recurrence.subscriptionFrequencyLabel + } + return t("subscriptions__renews_date", variables: ["date": renewal.formatted(.dateTime.month(.wide).day())]) + } +} diff --git a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift index 236b1f712..aaadd750b 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift @@ -1,9 +1,12 @@ import SwiftUI struct ReceiveQr: View { + @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false + @EnvironmentObject private var app: AppViewModel @EnvironmentObject private var blocktank: BlocktankViewModel @EnvironmentObject private var wallet: WalletViewModel + @Environment(PaykitPaymentRequestManager.self) private var paymentRequests @Binding var navigationPath: [ReceiveRoute] let cjitInvoice: String? let tab: ReceiveTab? @@ -68,7 +71,7 @@ struct ReceiveQr: View { var body: some View { VStack(spacing: 0) { - SheetHeader(title: t("wallet__receive_bitcoin")) + SheetHeader(title: t("wallet__receive_bitcoin"), action: paymentRequestAction) .padding(.horizontal, 16) .padding(.bottom, UIScreen.main.isSmall ? -16 : 0) @@ -166,6 +169,27 @@ struct ReceiveQr: View { } } + private var paymentRequestAction: AnyView? { + guard PaykitFeatureFlags.isUIAvailable, + isPaykitUIEnabled, + !paymentRequests.eligibleTargets.isEmpty + else { return nil } + + return AnyView( + Button { + navigationPath.append(.paymentRequestRecipient(ReceiveSheet.defaultPaymentRequestDraft)) + } label: { + Image("users") + .resizable() + .scaledToFit() + .foregroundColor(.textPrimary) + .frame(width: 24, height: 24) + } + .accessibilityLabel(t("wallet__payment_request_request_payment")) + .accessibilityIdentifier("ReceiveRequestPayment") + ) + } + func tabContent(for tab: ReceiveTab) -> some View { VStack(spacing: 0) { if tab == .spending && wallet.channelCount == 0 && cjitInvoice == nil { diff --git a/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift b/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift index c917fb3e7..5cc978657 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift @@ -9,8 +9,10 @@ enum ReceiveRoute: Hashable { case cjitConfirm(entry: IcJitEntry, receiveAmountSats: UInt64, isAdditional: Bool) case cjitLearnMore(entry: IcJitEntry, receiveAmountSats: UInt64, isAdditional: Bool) case cjitGeoBlocked - case paymentRequestDetails(PaykitPaymentRequestDraft) + case requestOrPay(publicKey: String) case paymentRequestRecipient(PaykitPaymentRequestDraft) + case paymentRequestAmount(PaykitPaymentRequestDraft, PaykitPaymentRequestTarget) + case paymentRequestDetails(PaykitPaymentRequestDraft, PaykitPaymentRequestTarget) case paymentRequestSent(PaykitPaymentRequest) } @@ -87,21 +89,28 @@ struct ReceiveSheet: View { ReceiveCjitLearnMore(entry: entry, receiveAmountSats: receiveAmountSats, isAdditional: isAdditional) case .cjitGeoBlocked: ReceiveCjitGeoBlocked() - case let .paymentRequestDetails(draft): - PaymentRequestDetailsView(initialDraft: draft) { updatedDraft in - if navigationPath.count >= 2, - case .paymentRequestDetails = navigationPath[navigationPath.count - 1], - case .paymentRequestRecipient = navigationPath[navigationPath.count - 2] - { - navigationPath.removeLast(2) - } - navigationPath.append(.paymentRequestRecipient(updatedDraft)) + case let .requestOrPay(publicKey): + RequestOrPayView(publicKey: publicKey) { target in + navigationPath.append(.paymentRequestAmount(Self.defaultPaymentRequestDraft, target)) } case let .paymentRequestRecipient(draft): - PaymentRequestRecipientView( - draft: draft, - onEditExpiration: { - navigationPath.append(.paymentRequestDetails(draft)) + PaymentRequestRecipientView { target in + if draft.amountSats == 0 { + navigationPath.append(.paymentRequestAmount(draft, target)) + } else { + navigationPath.append(.paymentRequestDetails(draft, target)) + } + } + case let .paymentRequestAmount(draft, target): + PaymentRequestAmountView(initialDraft: draft, target: target) { updatedDraft in + navigationPath.append(.paymentRequestDetails(updatedDraft, target)) + } + case let .paymentRequestDetails(draft, target): + PaymentRequestDetailsView( + initialDraft: draft, + target: target, + onEditAmount: { updatedDraft in + navigationPath.append(.paymentRequestAmount(updatedDraft, target)) }, onSent: { request in navigationPath.append(.paymentRequestSent(request)) @@ -111,4 +120,12 @@ struct ReceiveSheet: View { PaymentRequestSentView(request: request) } } + + static var defaultPaymentRequestDraft: PaykitPaymentRequestDraft { + PaykitPaymentRequestDraft( + amountSats: 0, + note: "", + expiresAt: PaymentRequestExpiration.week.date(from: Date()) + ) + } } diff --git a/Bitkit/Views/Wallets/Send/InitialSubscriptionPaymentProgress.swift b/Bitkit/Views/Wallets/Send/InitialSubscriptionPaymentProgress.swift new file mode 100644 index 000000000..b3938eaa7 --- /dev/null +++ b/Bitkit/Views/Wallets/Send/InitialSubscriptionPaymentProgress.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct InitialSubscriptionPaymentProgress: View { + var body: some View { + VStack(spacing: 0) { + SheetHeader( + title: t("subscriptions__review_and_subscribe"), + action: AnyView(SendContactHeaderAvatar()) + ) + Spacer() + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .purpleAccent)) + .scaleEffect(1.25) + Spacer() + } + .padding(.horizontal, 16) + .sheetBackground() + } +} + +func paykitPaymentReviewTitle(context: ContactPaymentContext?, fallback: String) -> String { + guard let request = context?.incomingPaymentRequest else { return fallback } + if context?.isInitialSubscriptionPayment == true { + return t("subscriptions__review_and_subscribe") + } + return request.billingPeriod == nil ? t("wallet__payment_request") : t("subscriptions__subscription") +} diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift index f17c4cc3e..04c1ba083 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift @@ -19,6 +19,7 @@ struct LnurlPayConfirm: View { @State private var showingBiometricError = false @State private var biometricErrorMessage = "" @State private var comment = "" + @State private var hasStartedAutomaticPayment = false @FocusState private var isCommentFocused: Bool var uri: String { @@ -26,9 +27,41 @@ struct LnurlPayConfirm: View { } var body: some View { + ZStack { + confirmationContent + if app.contactPaymentContext?.isInitialSubscriptionPayment == true { + InitialSubscriptionPaymentProgress() + } + } + .alert(t("common__are_you_sure"), isPresented: $showWarningAlert) { + Button(t("common__dialog_cancel"), role: .cancel) { + alertContinuation?.resume(returning: false) + alertContinuation = nil + } + Button(t("wallet__send_yes")) { + alertContinuation?.resume(returning: true) + alertContinuation = nil + } + } message: { + Text(t("wallet__send_dialog1")) + } + .alert( + t("security__bio_error_title"), + isPresented: $showingBiometricError + ) { + Button(t("common__ok")) { + // Error handled, user acknowledged + } + } message: { + Text(biometricErrorMessage) + } + .task { await startAutomaticPaymentIfNeeded() } + } + + private var confirmationContent: some View { VStack { SheetHeader( - title: app.contactPaymentContext?.incomingPaymentRequest == nil ? t("wallet__lnurl_p_title") : t("wallet__payment_request"), + title: reviewTitle, showBackButton: true, action: AnyView(SendContactHeaderAvatar()) ) @@ -103,8 +136,11 @@ struct LnurlPayConfirm: View { Spacer() SwipeButton( - title: t("wallet__send_swipe"), - accentColor: .greenAccent + title: app.contactPaymentContext?.isInitialSubscriptionPayment == true + ? t("subscriptions__swipe_to_subscribe_and_pay") + : t("wallet__send_swipe"), + accentColor: .greenAccent, + isLoading: hasStartedAutomaticPayment ) { try await submitPayment() } @@ -112,27 +148,36 @@ struct LnurlPayConfirm: View { .navigationBarHidden(true) .padding(.horizontal, 16) .sheetBackground() - .alert(t("common__are_you_sure"), isPresented: $showWarningAlert) { - Button(t("common__dialog_cancel"), role: .cancel) { - alertContinuation?.resume(returning: false) - alertContinuation = nil - } - Button(t("wallet__send_yes")) { - alertContinuation?.resume(returning: true) - alertContinuation = nil - } - } message: { - Text(t("wallet__send_dialog1")) - } - .alert( - t("security__bio_error_title"), - isPresented: $showingBiometricError - ) { - Button(t("common__ok")) { - // Error handled, user acknowledged - } - } message: { - Text(biometricErrorMessage) + } + + private var reviewTitle: String { + paykitPaymentReviewTitle(context: app.contactPaymentContext, fallback: t("wallet__lnurl_p_title")) + } + + @MainActor + private func startAutomaticPaymentIfNeeded() async { + guard app.contactPaymentContext?.isInitialSubscriptionPayment == true, + !hasStartedAutomaticPayment + else { return } + hasStartedAutomaticPayment = true + do { + try await submitPayment() + } catch is CancellationError { + navigationPath.append(.failure(SendFailureContext( + error: CancellationError(), + retryRoute: .lnurlPayConfirm, + routingCacheResetAttempted: routingCacheResetAttempted, + paymentRequest: app.scannedLightningInvoice?.bolt11, + contactPaymentContext: app.contactPaymentContext + ))) + } catch { + navigationPath.append(.failure(SendFailureContext( + error: error, + retryRoute: .lnurlPayConfirm, + routingCacheResetAttempted: routingCacheResetAttempted, + paymentRequest: "LNURL: \(uri)", + contactPaymentContext: app.contactPaymentContext + ))) } } @@ -198,7 +243,6 @@ struct LnurlPayConfirm: View { let amountMsats = lnurlPayData.callbackAmountMsats(userSats: wallet.sendAmountSats) let contactPaymentContext = app.contactPaymentContext - let contactPublicKey = contactPaymentContext?.publicKey let incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest var bolt11Invoice: String? var lightningPaymentHash: String? @@ -243,12 +287,12 @@ struct LnurlPayConfirm: View { bolt11: bolt11, sats: nil, onTimeout: { - app.addPendingPaymentHash(paymentHash, contactPublicKey: contactPublicKey) + app.addPendingPaymentHash(paymentHash, contactPaymentContext: contactPaymentContext) navigationPath.append(.pending(paymentHash: paymentHash, retryRoute: .lnurlPayConfirm, paymentRequest: bolt11)) } ) shouldCancelPaymentProof = false - app.addPendingContactPaymentContext(paymentHash, contactPublicKey: contactPublicKey) + app.addPendingContactPaymentContext(paymentHash, context: contactPaymentContext) Logger.info("LNURL payment successful: \(paymentHash)") navigationPath.append(.success(paymentId: paymentHash)) } catch is PaymentTimeoutError { @@ -268,7 +312,8 @@ struct LnurlPayConfirm: View { error: error, retryRoute: .lnurlPayConfirm, routingCacheResetAttempted: routingCacheResetAttempted, - paymentRequest: bolt11Invoice ?? "LNURL: \(lnurlPayData.uri)" + paymentRequest: bolt11Invoice ?? "LNURL: \(lnurlPayData.uri)", + contactPaymentContext: contactPaymentContext ))) } } diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 09d8d8f27..1f3ca0b14 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -26,6 +26,7 @@ struct SendConfirmationView: View { @State private var pendingWarnings: [WarningType] = [] @State private var warningContinuation: CheckedContinuation? @State private var swipeProgress: CGFloat = 0 + @State private var hasStartedAutomaticPayment = false var accentColor: Color { app.selectedWalletToPayFrom == .lightning ? .purpleAccent : .brandAccent @@ -100,9 +101,67 @@ struct SendConfirmationView: View { } var body: some View { + ZStack { + confirmationContent + if app.contactPaymentContext?.isInitialSubscriptionPayment == true { + InitialSubscriptionPaymentProgress() + } + } + .task { + ensureSendAmountFromScannedInvoicesIfNeeded() + await calculateTransactionFee() + await calculateRoutingFee() + await startAutomaticPaymentIfNeeded() + } + .onChange(of: wallet.selectedFeeRateSatsPerVByte) { + Task { + await calculateTransactionFee() + } + } + .onChange(of: app.selectedWalletToPayFrom) { + Task { + if app.selectedWalletToPayFrom == .lightning { + await MainActor.run { transactionFee = 0 } + } else { + await onSwitchToOnchainWallet() + } + } + } + .alert( + t("security__bio_error_title"), + isPresented: $showingBiometricError + ) { + Button(t("common__ok")) { + // Error handled, user acknowledged + } + } message: { + Text(biometricErrorMessage) + } + .alert( + currentWarning?.title ?? "", + isPresented: .constant(currentWarning != nil) + ) { + Button(t("common__dialog_cancel"), role: .cancel) { + warningContinuation?.resume(returning: false) + warningContinuation = nil + currentWarning = nil + } + Button(t("wallet__send_yes")) { + warningContinuation?.resume(returning: true) + warningContinuation = nil + currentWarning = nil + } + } message: { + if let warning = currentWarning { + Text(warning.message) + } + } + } + + private var confirmationContent: some View { VStack(alignment: .leading, spacing: 0) { SheetHeader( - title: app.contactPaymentContext?.incomingPaymentRequest == nil ? t("wallet__send_review") : t("wallet__payment_request"), + title: reviewTitle, showBackButton: !navigationPath.isEmpty, action: AnyView(SendContactHeaderAvatar()) ) @@ -163,7 +222,14 @@ struct SendConfirmationView: View { .accessibilityIdentifier("SendConfirmToggleDetails") } - SwipeButton(title: t("wallet__send_swipe"), accentColor: accentColor, swipeProgress: $swipeProgress) { + SwipeButton( + title: app.contactPaymentContext?.isInitialSubscriptionPayment == true + ? t("subscriptions__swipe_to_subscribe_and_pay") + : t("wallet__send_swipe"), + accentColor: accentColor, + isLoading: hasStartedAutomaticPayment, + swipeProgress: $swipeProgress + ) { try await submitPayment() } } @@ -171,53 +237,41 @@ struct SendConfirmationView: View { .padding(.horizontal, 16) .sheetBackground() .frame(maxWidth: .infinity, maxHeight: .infinity) - .task { - ensureSendAmountFromScannedInvoicesIfNeeded() - await calculateTransactionFee() - await calculateRoutingFee() - } - .onChange(of: wallet.selectedFeeRateSatsPerVByte) { - Task { - await calculateTransactionFee() - } - } - .onChange(of: app.selectedWalletToPayFrom) { - Task { - if app.selectedWalletToPayFrom == .lightning { - await MainActor.run { transactionFee = 0 } - } else { - await onSwitchToOnchainWallet() - } - } - } - .alert( - t("security__bio_error_title"), - isPresented: $showingBiometricError - ) { - Button(t("common__ok")) { - // Error handled, user acknowledged - } - } message: { - Text(biometricErrorMessage) - } - .alert( - currentWarning?.title ?? "", - isPresented: .constant(currentWarning != nil) - ) { - Button(t("common__dialog_cancel"), role: .cancel) { - warningContinuation?.resume(returning: false) - warningContinuation = nil - currentWarning = nil - } - Button(t("wallet__send_yes")) { - warningContinuation?.resume(returning: true) - warningContinuation = nil - currentWarning = nil - } - } message: { - if let warning = currentWarning { - Text(warning.message) + } + + private var reviewTitle: String { + paykitPaymentReviewTitle(context: app.contactPaymentContext, fallback: t("wallet__send_review")) + } + + @MainActor + private func startAutomaticPaymentIfNeeded() async { + guard app.contactPaymentContext?.isInitialSubscriptionPayment == true, + !hasStartedAutomaticPayment + else { return } + hasStartedAutomaticPayment = true + do { + if app.selectedWalletToPayFrom == .onchain, + wallet.selectedFeeRateSatsPerVByte == nil + { + try await wallet.setFeeRate(speed: settings.defaultTransactionSpeed) } + try await submitPayment() + } catch is CancellationError { + navigationPath.append(.failure(SendFailureContext( + error: CancellationError(), + retryRoute: .confirm, + routingCacheResetAttempted: routingCacheResetAttempted, + paymentRequest: app.scannedLightningInvoice?.bolt11, + contactPaymentContext: app.contactPaymentContext + ))) + } catch { + navigationPath.append(.failure(SendFailureContext( + error: error, + retryRoute: .confirm, + routingCacheResetAttempted: routingCacheResetAttempted, + paymentRequest: app.scannedLightningInvoice?.bolt11, + contactPaymentContext: app.contactPaymentContext + ))) } } @@ -496,6 +550,7 @@ struct SendConfirmationView: View { let incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest var shouldCancelPaymentProof = false var preparedPaymentProof: (endpointIdentifier: String, kind: PaykitPaymentProofKind)? + var onchainPaymentStarted = false do { try validateIncomingPaymentRequestContext(contactPaymentContext) @@ -538,7 +593,7 @@ struct SendConfirmationView: View { bolt11: invoice.bolt11, sats: paymentSats, onTimeout: { - app.addPendingPaymentHash(paymentHash, contactPublicKey: contactPublicKey) + app.addPendingPaymentHash(paymentHash, contactPaymentContext: contactPaymentContext) navigationPath.append(.pending(paymentHash: paymentHash, retryRoute: .confirm, paymentRequest: invoice.bolt11)) } ) @@ -557,7 +612,19 @@ struct SendConfirmationView: View { } else if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice { let amount = wallet.sendAmountSats ?? invoice.amountSatoshis let useMaxAmount = await shouldUseMaxOnchainSend(address: invoice.address, amountSats: amount) - let txid = try await wallet.send(address: invoice.address, sats: amount, isMaxAmount: useMaxAmount) + let txid = try await wallet.send( + address: invoice.address, + sats: amount, + isMaxAmount: useMaxAmount + ) { + if let incomingPaymentRequest { + try await PaykitPaymentProofService.shared.markOnchainPaymentStarted( + incomingPaymentRequest, + address: invoice.address + ) + onchainPaymentStarted = true + } + } shouldCancelPaymentProof = false if let incomingPaymentRequest, let preparedPaymentProof { await PaykitPaymentProofService.shared.completeOnchainPayment( @@ -592,6 +659,23 @@ struct SendConfirmationView: View { ) } } catch { + if onchainPaymentStarted, let incomingPaymentRequest { + if isDefiniteOnchainPreBroadcastFailure(error) { + await PaykitPaymentProofService.shared.failOnchainPayment(incomingPaymentRequest) + onchainPaymentStarted = false + } else { + shouldCancelPaymentProof = false + wallet.sendAmountSats = incomingPaymentRequest.amountSats + Logger.warn("On-chain payment outcome is uncertain after broadcast started: \(error)", context: "SendConfirmation") + navigationPath.append(.pending( + paymentHash: incomingPaymentRequest.paymentRequestId, + retryRoute: .confirm, + paymentRequest: nil, + paykitPaymentRequestId: incomingPaymentRequest.id + )) + return + } + } if shouldCancelPaymentProof, let incomingPaymentRequest { await PaykitPaymentProofService.shared.cancelPreparation(incomingPaymentRequest) } @@ -605,11 +689,34 @@ struct SendConfirmationView: View { error: error, retryRoute: .confirm, routingCacheResetAttempted: routingCacheResetAttempted, - paymentRequest: app.selectedWalletToPayFrom == .lightning ? app.scannedLightningInvoice?.bolt11 : nil + paymentRequest: app.selectedWalletToPayFrom == .lightning ? app.scannedLightningInvoice?.bolt11 : nil, + contactPaymentContext: contactPaymentContext ))) } } + private func isDefiniteOnchainPreBroadcastFailure(_ error: Error) -> Bool { + let underlyingError = (error as? AppError)?.underlyingError ?? error + if let serviceError = underlyingError as? CustomServiceError { + switch serviceError { + case .nodeNotSetup, .nodeNotStarted: + return true + default: + return false + } + } + guard let nodeError = underlyingError as? NodeError else { return false } + + switch nodeError { + case .NotRunning, .OnchainTxCreationFailed, .OnchainWalletAccountNotRegistered, + .OnchainTxSigningFailed, .InvalidAddress, .InvalidAmount, .InvalidNetwork, + .InvalidFeeRate, .InsufficientFunds, .CoinSelectionFailed, .NoSpendableOutputs: + return true + default: + return false + } + } + private func paymentProofPreparation() throws -> (endpointIdentifier: String, kind: PaykitPaymentProofKind) { switch app.selectedWalletToPayFrom { case .lightning: @@ -660,7 +767,7 @@ struct SendConfirmationView: View { } do { - app.addPendingContactPaymentContext(paymentId, contactPublicKey: contactPublicKey) + app.addPendingContactPaymentContext(paymentId, context: app.contactPaymentContext) try await activityList.setContact(contactPublicKey, forPaymentId: paymentId) app.consumeContactPaymentContext(forPendingPaymentHash: paymentId) } catch { diff --git a/Bitkit/Views/Wallets/Send/SendFailure.swift b/Bitkit/Views/Wallets/Send/SendFailure.swift index 829dd5b17..816f5afb5 100644 --- a/Bitkit/Views/Wallets/Send/SendFailure.swift +++ b/Bitkit/Views/Wallets/Send/SendFailure.swift @@ -65,8 +65,12 @@ struct SendFailure: View { let context: SendFailureContext let onRetryReady: (Bool) -> Void + let onSecondaryAction: (() -> Void)? private var title: String { + if context.isInitialSubscriptionPayment { + return t("subscriptions__first_payment_failed") + } switch context.retryRoute { case .confirm: return app.selectedWalletToPayFrom == .lightning ? t("wallet__send_instant_failed") : t("wallet__send_error_tx_failed") @@ -99,16 +103,20 @@ struct SendFailure: View { VStack(spacing: 16) { CustomButton( - title: t("wallet__send_error_support"), + title: context.isInitialSubscriptionPayment ? t("wallet__payment_requests_not_now") : t("wallet__send_error_support"), variant: .secondary, isDisabled: wallet.isRetryingLightningPayment ) { - contactSupport() + if let onSecondaryAction { + onSecondaryAction() + } else { + contactSupport() + } } - .accessibilityIdentifier("Support") + .accessibilityIdentifier(context.isInitialSubscriptionPayment ? "NotNow" : "Support") CustomButton( - title: t("common__try_again"), + title: context.isInitialSubscriptionPayment ? t("subscriptions__retry_payment") : t("common__try_again"), isLoading: wallet.isRetryingLightningPayment ) { retryPayment() diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index d93392b7d..c89536ab0 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -27,12 +27,14 @@ struct SendPendingScreen: View { let paymentHash: String let retryRoute: SendRetryRoute let paymentRequest: String? + let paykitPaymentRequestId: PaykitPaymentRequest.ID? let routingCacheResetAttempted: Bool @Binding var navigationPath: [SendRoute] @EnvironmentObject private var activityList: ActivityListViewModel @EnvironmentObject private var app: AppViewModel @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var pubkyProfile: PubkyProfileManager @EnvironmentObject private var sheets: SheetViewModel @EnvironmentObject private var wallet: WalletViewModel @@ -89,15 +91,31 @@ struct SendPendingScreen: View { navigationPath.append(.success(paymentId: paymentHash)) } } else { + let contactPaymentContext = app.contactPaymentContext(forPendingPaymentHash: paymentHash) ?? app.contactPaymentContext app.consumeContactPaymentContext(forPendingPaymentHash: paymentHash) navigationPath.append(.failure(SendFailureContext( error: AppError(paymentFailureReason: resolution.failureReason), retryRoute: retryRoute, routingCacheResetAttempted: routingCacheResetAttempted, - paymentRequest: paymentRequest + paymentRequest: paymentRequest, + contactPaymentContext: contactPaymentContext ))) } } + .onReceive(PaykitPaymentProofService.onchainPaymentResolutionPublisher) { resolution in + guard resolution.requestId == paykitPaymentRequestId, + let identity = pubkyProfile.publicKey, + PubkyPublicKeyFormat.matches(resolution.identity, identity) + else { return } + app.addPendingContactPaymentContext( + resolution.transactionId, + context: ContactPaymentContext(publicKey: resolution.requestId.counterparty) + ) + Task { + await PaykitPaymentProofService.shared.consumeOnchainPaymentResolution(resolution) + navigationPath.append(.success(paymentId: resolution.transactionId)) + } + } } private func searchForActivity() async { diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 88124988c..8ee9c80eb 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -21,16 +21,28 @@ struct SendFailureContext: Hashable { let failureType: String let paymentRequest: String? let routingCacheResetAttempted: Bool - - init(error: Error, retryRoute: SendRetryRoute, routingCacheResetAttempted: Bool = false, paymentRequest: String? = nil) { + let incomingPaymentRequestId: PaykitPaymentRequest.ID? + let isInitialSubscriptionPayment: Bool + + init( + error: Error, + retryRoute: SendRetryRoute, + routingCacheResetAttempted: Bool = false, + paymentRequest: String? = nil, + contactPaymentContext: ContactPaymentContext? = nil + ) { let shouldResetRoutingCaches = shouldResetRoutingCachesOnRetry(for: error) - message = sendFailureMessage(for: error) + isInitialSubscriptionPayment = contactPaymentContext?.isInitialSubscriptionPayment == true + message = isInitialSubscriptionPayment + ? t("subscriptions__first_payment_failed_description") + : sendFailureMessage(for: error) self.retryRoute = retryRoute resetRoutingCachesOnRetry = shouldResetRoutingCaches && !routingCacheResetAttempted failureType = sendFailureType(for: error) self.paymentRequest = paymentRequest self.routingCacheResetAttempted = routingCacheResetAttempted + incomingPaymentRequestId = contactPaymentContext?.incomingPaymentRequest?.id } } @@ -47,7 +59,12 @@ enum SendRoute: Hashable { case tag case quickpay case pin - case pending(paymentHash: String, retryRoute: SendRetryRoute, paymentRequest: String?) + case pending( + paymentHash: String, + retryRoute: SendRetryRoute, + paymentRequest: String?, + paykitPaymentRequestId: PaykitPaymentRequest.ID? = nil + ) case success(paymentId: String) case failure(SendFailureContext) case lnurlPayAmount @@ -88,7 +105,6 @@ struct SendSheet: View { @State private var navigationPath: [SendRoute] = [] @State private var hasValidatedAfterSync = false - @State private var incomingPaymentRequest: PaykitPaymentRequest? @State private var routingCacheResetAttempted = false @State private var syncTimedOut = false @State private var pinCheckContinuations: [CheckedContinuation] = [] @@ -139,6 +155,13 @@ struct SendSheet: View { ) } + private var startsOnFailure: Bool { + if case .failure = config.initialRoute { + return true + } + return false + } + var body: some View { Sheet(id: .send, data: config) { if shouldShowSyncOverlay { @@ -160,21 +183,30 @@ struct SendSheet: View { Logger.debug("shouldShowSyncOverlay: \(isShowing) (node: \(wallet.nodeLifecycleState))", context: "SendSheet") } .onAppear { - tagManager.clearSelectedTags() wallet.resetSendState(speed: settings.defaultTransactionSpeed) if let request = app.contactPaymentContext?.incomingPaymentRequest { - incomingPaymentRequest = request + if !tagManager.consumePreservedTags(for: request.id) { + tagManager.clearSelectedTags() + } guard paykitPaymentRequestManager.markPresentedIfPending(request) else { app.resetSendState() sheets.hideSheetIfActive(.send, reason: "Incoming payment request is no longer available") return } + if PaykitSubscriptionNotificationTargetStore.load()?.matches(request) == true { + PaykitSubscriptionNotificationTargetStore.clear() + } wallet.sendAmountSats = request.amountSats + } else { + tagManager.clearSelectedTags() } hasValidatedAfterSync = false syncTimedOut = false - if app.contactPaymentContext?.incomingPaymentRequest != nil, !shouldShowSyncOverlay { + if app.contactPaymentContext?.incomingPaymentRequest != nil, + !shouldShowSyncOverlay, + !startsOnFailure + { validatePaymentAfterSync() } @@ -192,12 +224,6 @@ struct SendSheet: View { } } } - .onDisappear { - if let incomingPaymentRequest { - paykitPaymentRequestManager.finishPayment(incomingPaymentRequest) - } - app.contactPaymentContext = nil - } .onChange(of: wallet.nodeLifecycleState) { _, state in // When the node becomes running and we have a scanned invoice, run deferred validation. // This covers: @@ -267,8 +293,7 @@ struct SendSheet: View { if invoiceAmount > 0 { guard onchainBalance >= invoiceAmount else { let amountNeeded = invoiceAmount - onchainBalance - app.toast( - type: .error, + showPaymentSetupFailure( title: t("other__pay_insufficient_savings"), description: t( "other__pay_insufficient_savings_amount_description", @@ -276,19 +301,16 @@ struct SendSheet: View { ), accessibilityIdentifier: "InsufficientSavingsToast" ) - sheets.hideSheet() return false } } else { // Zero-amount invoice: user must have some balance to proceed guard onchainBalance > 0 else { - app.toast( - type: .error, + showPaymentSetupFailure( title: t("other__pay_insufficient_savings"), description: t("other__pay_insufficient_savings_description"), accessibilityIdentifier: "InsufficientSavingsToast" ) - sheets.hideSheet() return false } } @@ -301,14 +323,34 @@ struct SendSheet: View { let description = amountNeeded > 0 ? t("other__pay_insufficient_spending_amount_description", variables: ["amount": CurrencyFormatter.formatSats(amountNeeded)]) : t("other__pay_insufficient_spending_description") - app.toast( - type: .error, + showPaymentSetupFailure( title: t("other__pay_insufficient_spending"), description: description, accessibilityIdentifier: "InsufficientSpendingToast" ) } + private func showPaymentSetupFailure(title: String, description: String, accessibilityIdentifier: String) { + if let context = app.contactPaymentContext, context.isInitialSubscriptionPayment { + let error = AppError(message: title, debugMessage: description) + navigationPath.append(.failure(SendFailureContext( + error: error, + retryRoute: app.lnurlPayData == nil ? .confirm : .lnurlPayConfirm, + paymentRequest: app.scannedLightningInvoice?.bolt11, + contactPaymentContext: context + ))) + return + } + + app.toast( + type: .error, + title: title, + description: description, + accessibilityIdentifier: accessibilityIdentifier + ) + sheets.hideSheet() + } + /// Validates payment affordability after sync completes /// For lightning: falls back to onchain for unified invoices, shows error for pure lightning invoices /// For onchain: validates balance and shows error if insufficient @@ -319,8 +361,7 @@ struct SendSheet: View { if let lnurlPayData = app.lnurlPayData, let requestedAmount { let minimumAmount = max(1, lnurlPayData.minSendableSat) guard requestedAmount >= minimumAmount else { - app.toast( - type: .error, + showPaymentSetupFailure( title: t("wallet__lnurl_pay__error_min__title"), description: t( "wallet__lnurl_pay__error_min__description", @@ -328,25 +369,21 @@ struct SendSheet: View { ), accessibilityIdentifier: "LnurlPayAmountTooLowToast" ) - sheets.hideSheet() hasValidatedAfterSync = true return } guard requestedAmount <= lnurlPayData.maxSendableSat else { - app.toast( - type: .error, + showPaymentSetupFailure( title: t("wallet__lnurl_pay__error_max__title"), description: t("wallet__lnurl_pay__error_max__description"), accessibilityIdentifier: "LnurlPayAmountTooHighToast" ) - sheets.hideSheet() hasValidatedAfterSync = true return } guard LightningService.shared.canSend(amountSats: requestedAmount) else { let spendingBalance = LightningService.shared.balances?.totalLightningBalanceSats ?? 0 showInsufficientSpendingToast(invoiceAmount: requestedAmount, spendingBalance: spendingBalance) - sheets.hideSheet() hasValidatedAfterSync = true return } @@ -400,7 +437,6 @@ struct SendSheet: View { // For pure lightning invoices, show error toast and dismiss sheet let spendingBalance = LightningService.shared.balances?.totalLightningBalanceSats ?? 0 showInsufficientSpendingToast(invoiceAmount: paymentAmount, spendingBalance: spendingBalance) - sheets.hideSheet() hasValidatedAfterSync = true return } @@ -479,16 +515,20 @@ struct SendSheet: View { SendQuickpay(navigationPath: $navigationPath, routingCacheResetAttempted: routingCacheResetAttempted) case .pin: SendPinScreen(onCancel: { resolvePinCheck(false) }, onPinVerified: { resolvePinCheck(true) }) - case let .pending(paymentHash, retryRoute, paymentRequest): + case let .pending(paymentHash, retryRoute, paymentRequest, paykitPaymentRequestId): SendPendingScreen( paymentHash: paymentHash, retryRoute: retryRoute, paymentRequest: paymentRequest, + paykitPaymentRequestId: paykitPaymentRequestId, routingCacheResetAttempted: routingCacheResetAttempted, navigationPath: $navigationPath ) case let .success(paymentId): - SendSuccess(paymentId: paymentId) + SendSuccess( + paymentId: paymentId, + isInitialSubscriptionPayment: app.contactPaymentContext?.isInitialSubscriptionPayment == true + ) case let .failure(context): SendFailure( context: context, @@ -496,8 +536,18 @@ struct SendSheet: View { if didResetRoutingCaches { routingCacheResetAttempted = true } - resetNavigationForRetry(context.retryRoute) - } + if let requestId = context.incomingPaymentRequestId { + retryIncomingPaymentRequest( + requestId, + isInitialSubscriptionPayment: context.isInitialSubscriptionPayment + ) + } else { + resetNavigationForRetry(context.retryRoute) + } + }, + onSecondaryAction: context.isInitialSubscriptionPayment + ? { sheets.hideSheet(reason: "Initial subscription payment deferred") } + : nil ) case .lnurlPayAmount: LnurlPayAmount(navigationPath: $navigationPath) @@ -539,6 +589,24 @@ struct SendSheet: View { let route = retryRoute.sendRoute navigationPath = route == config.initialRoute ? [] : [route] } + + private func retryIncomingPaymentRequest( + _ requestId: PaykitPaymentRequest.ID, + isInitialSubscriptionPayment: Bool + ) { + guard let request = paykitPaymentRequestManager.paymentRequestForRetry(requestId) else { + app.toast(PaykitPaymentRequestError.requestUnavailable) + return + } + + app.resetSendState() + wallet.resetSendState(speed: settings.defaultTransactionSpeed) + _ = paykitPaymentRequestManager.requestPresentation( + request, + isInitialSubscriptionPayment: isInitialSubscriptionPayment + ) + sheets.hideSheet(reason: "Retrying incoming payment request with fresh private payment details") + } } private struct SendComingSoonView: View { diff --git a/Bitkit/Views/Wallets/Send/SendSuccess.swift b/Bitkit/Views/Wallets/Send/SendSuccess.swift index c2170f0f6..4d2250ca5 100644 --- a/Bitkit/Views/Wallets/Send/SendSuccess.swift +++ b/Bitkit/Views/Wallets/Send/SendSuccess.swift @@ -10,6 +10,7 @@ struct SendSuccess: View { @EnvironmentObject var wallet: WalletViewModel let paymentId: String // The payment hash or txid from the successful payment + let isInitialSubscriptionPayment: Bool @State private var foundActivity: Activity? @@ -43,6 +44,22 @@ struct SendSuccess: View { } var body: some View { + Group { + if isInitialSubscriptionPayment { + SubscriptionSuccessView { + sheets.hideSheet(reason: "Initial subscription payment completed") + } + } else { + standardSuccess + } + } + .navigationBarHidden(true) + .allowSwipeBack(false) + .sheetBackground() + .task { await searchForActivity() } + } + + private var standardSuccess: some View { VStack(alignment: .leading, spacing: 0) { ZStack { // Background confetti animation @@ -94,12 +111,6 @@ struct SendSuccess: View { } .padding(.horizontal, 16) } - .navigationBarHidden(true) - .allowSwipeBack(false) - .sheetBackground() - } - .task { - await searchForActivity() } } diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 0be0585e4..4249807a3 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -8,6 +8,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { private let counterparty = "pubky\(String(repeating: "y", count: 52))" private let paymentHash = "66687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925" private let preimage = String(repeating: "00", count: 32) + private let onchainAddress = "bcrt1qpaymentproof" func testCompletedLightningPaymentRetriesAfterRestart() async throws { let record = try paymentRequestRecord() @@ -27,8 +28,10 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let failedSubmissionCount = await sdk.submissionCount() let persistedProof = await store.snapshot().first + let completedRequestIds = await service.completedRequestIdsAwaitingSubmission(identity: identity) XCTAssertEqual(failedSubmissionCount, 1) XCTAssertEqual(persistedProof?.proofData, preimage) + XCTAssertEqual(completedRequestIds, Set([request.id])) await sdk.setSubmissionFailure(false) let restartedService = paymentProofService( @@ -120,6 +123,99 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertEqual(submissionCount, 0) } + func testUnstartedSubscriptionPreparationIsDiscardedBeforeCancellation() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + let record = try paymentRequestRecord(state: .activeRecurring, recurrence: recurrence) + let subscription = try XCTUnwrap(PaykitSubscription(record: record)) + let request = try XCTUnwrap(subscription.paymentDueOnAcceptance(at: now)) + let store = PaymentProofMemoryStore() + let service = paymentProofService( + sdk: PaymentProofSdkMock(identity: identity, records: [record]), + store: store + ) + + try await service.prepare( + request: request, + paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning + ) + + let protectedRequestIds = try await service.protectedRequestIdsForSubscriptionCancellation( + identity: identity, + subscriptionId: subscription.id + ) + XCTAssertTrue(protectedRequestIds.isEmpty) + let remainingProofs = await store.snapshot() + XCTAssertTrue(remainingProofs.isEmpty) + } + + func testStartedSubscriptionPaymentIsProtectedFromCancellation() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint], state: .activeRecurring, recurrence: recurrence) + let subscription = try XCTUnwrap(PaykitSubscription(record: record)) + let request = try XCTUnwrap(subscription.paymentDueOnAcceptance(at: now)) + let store = PaymentProofMemoryStore() + let service = paymentProofService( + sdk: PaymentProofSdkMock(identity: identity, records: [record]), + store: store + ) + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress) + + let protectedRequestIds = try await service.protectedRequestIdsForSubscriptionCancellation( + identity: identity, + subscriptionId: subscription.id + ) + + XCTAssertEqual(protectedRequestIds, [request.id]) + let remainingProofs = await store.snapshot() + XCTAssertEqual(remainingProofs.map(\.requestId), [request.id]) + } + + func testCancelPreparationDoesNotRemoveAnotherIdentityProof() async throws { + let record = try paymentRequestRecord() + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let otherIdentityProof = PendingPaykitPaymentProof( + identity: "pubky\(String(repeating: "x", count: 52))", + requestId: request.id, + paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning, + paymentIdentifier: nil, + proofData: nil + ) + await store.seed([otherIdentityProof]) + let service = paymentProofService( + sdk: PaymentProofSdkMock(identity: identity, records: [record]), + store: store + ) + + try await service.prepare( + request: request, + paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning + ) + await service.cancelPreparation(request) + + let remainingProofs = await store.snapshot() + XCTAssertEqual(remainingProofs, [otherIdentityProof]) + } + func testOnchainPaymentSubmitsTransactionIdForSelectedEndpoint() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) @@ -130,6 +226,9 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let txid = String(repeating: "ab", count: 32) try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress) + let inFlightRequestIds = await service.inFlightRequestIds(identity: identity) + XCTAssertEqual(inFlightRequestIds, [request.id]) await service.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: endpoint) let submittedProof = await sdk.lastSubmission() @@ -143,10 +242,113 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertTrue(remainingProofs.isEmpty) } - func testLightningRetryPreservesEarlierPaymentCorrelation() async throws { - let record = try paymentRequestRecord() + func testStartedOnchainPaymentSurvivesPreparationCancellation() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let service = paymentProofService(sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: store) + + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress) + await service.cancelPreparation(request) + + let storedProofs = await store.snapshot() + let proof = try XCTUnwrap(storedProofs.first) + XCTAssertTrue(proof.paymentStarted) + } + + func testUncertainOnchainPaymentReconcilesFromPrivateDestination() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord( + endpoints: [endpoint], + paymentRequestId: "550e8400-e29b-41d4-a716-446655440099" + ) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let txid = String(repeating: "ab", count: 32) + let service = paymentProofService(sdk: sdk, store: store, onchainTxids: [txid]) + let resolutionExpectation = expectation(description: "On-chain payment resolution published") + let resolution = PaykitPaymentProofService.onchainPaymentResolutionPublisher + .filter { $0.requestId == request.id } + .sink { + XCTAssertEqual($0.identity, self.identity) + XCTAssertEqual($0.transactionId, txid) + resolutionExpectation.fulfill() + } + + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress) + await service.reconcile() + await fulfillment(of: [resolutionExpectation], timeout: 1) + withExtendedLifetime(resolution) {} + + let submittedProof = await sdk.lastSubmission() + let submission = try XCTUnwrap(submittedProof) + XCTAssertEqual(submission.paymentEndpointIdentifier, endpoint) + XCTAssertEqual( + try proofValues(submission.proof.exportText()), + ["data": txid, "type": PaykitPaymentProofKind.onchain.rawValue] + ) + let remainingProofs = await store.snapshot() + XCTAssertTrue(remainingProofs.isEmpty) + } + + func testUncertainOnchainPaymentDoesNotReuseTransactionFromBeforeAttempt() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let oldTransactionId = String(repeating: "ab", count: 32) + let service = paymentProofService( + sdk: sdk, + store: store, + onchainTxids: [oldTransactionId], + existingOnchainTxids: [oldTransactionId] + ) + + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress) + await service.reconcile() + + let submissionCount = await sdk.submissionCount() + let storedProofs = await store.snapshot() + let storedProof = try XCTUnwrap(storedProofs.first) + XCTAssertEqual(submissionCount, 0) + XCTAssertNil(storedProof.proofData) + XCTAssertEqual(storedProof.onchainMatchingTransactionIdsBeforeAttempt, [oldTransactionId]) + } + + func testDefiniteOnchainFailureClearsStartedProof() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) let store = PaymentProofMemoryStore() + let service = paymentProofService(sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: store) + + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress) + await service.failOnchainPayment(request) + + let storedProofs = await store.snapshot() + XCTAssertTrue(storedProofs.isEmpty) + } + + func testRecurringPaymentSubmitsExactBillingPeriod() async throws { + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + let record = try paymentRequestRecord(state: .activeRecurring, recurrence: recurrence) + let subscription = try XCTUnwrap(PaykitSubscription(record: record)) + let acceptedAt = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let request = try XCTUnwrap(subscription.requests(through: acceptedAt, acceptedAt: acceptedAt).first) + let store = PaymentProofMemoryStore() let sdk = PaymentProofSdkMock(identity: identity, records: [record]) let service = paymentProofService(sdk: sdk, store: store) @@ -156,19 +358,90 @@ final class PaykitPaymentProofServiceTests: XCTestCase { kind: .lightning ) try await service.associateLightningPayment(request, paymentHash: paymentHash) + await service.completeLightningPayment(paymentHash: paymentHash, preimage: preimage) + + let submittedProof = await sdk.lastSubmission() + let submission = try XCTUnwrap(submittedProof) + XCTAssertEqual(submission.billingPeriod?.startsAt, "2027-01-01T08:00:00Z") + XCTAssertEqual(submission.billingPeriod?.endsAt, "2027-02-01T08:00:00Z") + } + + func testProofFromEarlierBillingPeriodDoesNotSuppressRecurringPayment() async throws { + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + let previousProof = try paymentProofRecord( + endpoint: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning, + data: String(repeating: "01", count: 32), + billingPeriod: BillingPeriod(startsAt: "2027-01-01T08:00:00.000Z", endsAt: "2027-02-01T08:00:00.000Z") + ) + let record = try paymentRequestRecord( + paymentProofs: [previousProof], + state: .activeRecurring, + recurrence: recurrence + ) + let subscription = try XCTUnwrap(PaykitSubscription(record: record)) + let date = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-02-15T08:00:00Z")) + let request = try XCTUnwrap(subscription.requests(through: date, acceptedAt: date).last) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store) + try await service.prepare( request: request, paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) - try await service.associateLightningPayment(request, paymentHash: String(repeating: "aa", count: 32)) - + try await service.associateLightningPayment(request, paymentHash: paymentHash) await service.completeLightningPayment(paymentHash: paymentHash, preimage: preimage) let submissionCount = await sdk.submissionCount() - let remainingProofs = await store.snapshot() + let submission = await sdk.lastSubmission() XCTAssertEqual(submissionCount, 1) - XCTAssertTrue(remainingProofs.isEmpty) + XCTAssertEqual(submission?.billingPeriod?.startsAt, "2027-02-01T08:00:00Z") + } + + func testLightningRetryIsRejectedWhileEarlierPaymentIsUnresolved() async throws { + let record = try paymentRequestRecord() + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store) + + try await service.prepare( + request: request, + paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning + ) + try await service.associateLightningPayment(request, paymentHash: paymentHash) + do { + try await service.prepare( + request: request, + paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning + ) + XCTFail("Expected a second unresolved payment attempt to be rejected") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .operationInProgress) + } + + let remainingProofs = await store.snapshot() + XCTAssertEqual(remainingProofs.count, 1) + XCTAssertEqual(remainingProofs.first?.paymentIdentifier, paymentHash) + + await service.failLightningPayment(paymentHash: paymentHash) + try await service.prepare( + request: request, + paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning + ) + let retryProofs = await store.snapshot() + XCTAssertEqual(retryProofs.count, 1) } func testClearedStoreDoesNotRestoreCachedProofs() async throws { @@ -207,6 +480,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let service = paymentProofService(sdk: sdk, store: store) try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress) await store.failNextSave() await service.completeOnchainPayment( request, @@ -220,6 +494,28 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertTrue(remainingProofs.isEmpty) } + func testCompletedOnchainProofRemainsDurableWhenPersistenceAndSubmissionInitiallyFail() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store) + let txid = String(repeating: "ab", count: 32) + + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress) + await store.failNextSave() + await sdk.setSubmissionFailure(true) + await service.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: endpoint) + + let storedProofs = await store.snapshot() + let proof = try XCTUnwrap(storedProofs.first) + XCTAssertEqual(proof.proofData, txid) + let submissionCount = await sdk.submissionCount() + XCTAssertEqual(submissionCount, 1) + } + func testOnchainPaymentSubmitsWhenPreparedProofCannotBeLoaded() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) @@ -230,6 +526,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let txid = String(repeating: "ab", count: 32) try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress) await store.failNextLoad() await service.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: endpoint) @@ -247,12 +544,18 @@ final class PaykitPaymentProofServiceTests: XCTestCase { private func paymentProofService( sdk: PaymentProofSdkMock, store: PaymentProofMemoryStore, - lightningStatus: PaykitLightningPaymentProofStatus = .unknown + lightningStatus: PaykitLightningPaymentProofStatus = .unknown, + onchainTxids: [String] = [], + existingOnchainTxids: Set = [] ) -> PaykitPaymentProofService { PaykitPaymentProofService( sdk: sdk, store: store, lightningPaymentLookup: PaymentProofLightningLookup(status: lightningStatus), + onchainPaymentLookup: PaymentProofOnchainLookup( + transactionIds: onchainTxids, + existingTransactionIds: existingOnchainTxids + ), logInfo: { _ in }, logWarning: { _ in } ) @@ -261,14 +564,16 @@ final class PaykitPaymentProofServiceTests: XCTestCase { private func paymentRequestRecord( endpoints: [String] = [PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue], paymentProofs: [PaymentProofRecord] = [], - paymentRequestId: String = "550e8400-e29b-41d4-a716-446655440000" + paymentRequestId: String = "550e8400-e29b-41d4-a716-446655440000", + state: PaymentRequestLifecycleState = .proposed, + recurrence: PaymentRequestRecurrence? = nil ) throws -> PaymentRequestRecord { try PaymentRequestRecord( counterparty: counterparty, counterpartyReceiverPath: PaykitReceiverPath.wallet, paymentRequestId: paymentRequestId, localRole: .payer, - state: .proposed, + state: state, proposalStreamItemId: 1, proposalOutboundMessageId: nil, proposalOutboundStatus: nil, @@ -277,7 +582,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { amount: PaymentRequestAmount(value: "0.00001", asset: "btc"), paymentReference: PaymentReference(text: "invoice-123"), proposalExpiresAt: nil, - recurrence: nil, + recurrence: recurrence, acceptedPaymentEndpointIdentifiers: endpoints, metadata: PrivateJsonObject(text: "{}") ), @@ -299,7 +604,8 @@ final class PaykitPaymentProofServiceTests: XCTestCase { private func paymentProofRecord( endpoint: String, kind: PaykitPaymentProofKind, - data: String + data: String, + billingPeriod: BillingPeriod? = nil ) throws -> PaymentProofRecord { try PaymentProofRecord( eventId: "750e8400-e29b-41d4-a716-446655440000", @@ -307,7 +613,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { outboundStatus: nil, streamItemId: 2, paymentReference: PaymentReference(text: "invoice-123"), - billingPeriod: nil, + billingPeriod: billingPeriod, paymentEndpointIdentifier: endpoint, proof: PrivateJsonObject(text: "{\"data\":\"\(data)\",\"type\":\"\(kind.rawValue)\"}"), recordedAt: "2027-01-15T08:01:00Z" @@ -356,6 +662,10 @@ private actor PaymentProofMemoryStore: PaykitPaymentProofStoring { func snapshot() -> [PendingPaykitPaymentProof] { proofs } + + func seed(_ proofs: [PendingPaykitPaymentProof]) { + self.proofs = proofs + } } private struct PaymentProofLightningLookup: PaykitLightningPaymentProofLookingUp { @@ -366,6 +676,19 @@ private struct PaymentProofLightningLookup: PaykitLightningPaymentProofLookingUp } } +private struct PaymentProofOnchainLookup: PaykitOnchainPaymentProofLookingUp { + let transactionIds: [String] + let existingTransactionIds: Set + + func existingTransactionIds(address _: String, amountSats _: UInt64) async throws -> Set { + existingTransactionIds + } + + func transactionId(address _: String, amountSats _: UInt64, excluding transactionIds: Set) async throws -> String? { + self.transactionIds.first { !transactionIds.contains($0) } + } +} + private actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling { private let identity: String private var records: [PaymentRequestRecord] diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 42d528107..79a7c3d50 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -1,6 +1,7 @@ @testable import Bitkit import Foundation import Paykit +import UserNotifications import XCTest @MainActor @@ -18,6 +19,159 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } } + func testSubscriptionNotificationTargetRoundTripsExactBillingPeriod() throws { + defer { PaykitSubscriptionNotificationTargetStore.clear() } + PaykitSubscriptionNotificationTargetStore.clear() + let counterparty = "pubky\(String(repeating: "y", count: 52))" + let payerIdentity = "pubky\(String(repeating: "z", count: 52))" + let userInfo: [AnyHashable: Any] = [ + "payer_identity": payerIdentity, + "payment_request_id": "subscription-id", + "counterparty": counterparty, + "counterparty_receiver_path": "bitkit/server", + "billing_period_starts_at": "2026-08-25T12:00:00Z", + ] + + let target = try XCTUnwrap(PaykitSubscriptionNotificationTarget(userInfo: userInfo)) + PaykitSubscriptionNotificationTargetStore.save(target) + + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "week", + startsAt: "2026-08-25T12:00:00Z", + anchor: "2026-08-25T12:00:00Z", + endsAt: nil + ) + let record = try paymentRequestRecord( + id: "subscription-id", + counterparty: counterparty, + state: .activeRecurring, + recurrence: recurrence + ) + let subscription = try XCTUnwrap(PaykitSubscription(record: record)) + let acceptedAt = try XCTUnwrap(ISO8601DateFormatter().date(from: "2026-08-24T12:00:00Z")) + let through = try XCTUnwrap(ISO8601DateFormatter().date(from: "2026-08-26T12:00:00Z")) + let request = try XCTUnwrap(subscription.requests(through: through, acceptedAt: acceptedAt).first) + + XCTAssertEqual(PaykitSubscriptionNotificationTargetStore.load(), target) + XCTAssertTrue(target.matches(identity: payerIdentity)) + XCTAssertTrue(target.matches(request)) + PaykitSubscriptionNotificationTargetStore.clear() + XCTAssertNil(PaykitSubscriptionNotificationTargetStore.load()) + } + + func testSubscriptionNotificationIdentifiersAreScopedToPayerIdentity() throws { + let startsAt = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-01T08:00:00Z")) + let requestId = PaykitPaymentRequest.ID( + paymentRequestId: "subscription", + counterparty: "pubkypayee", + counterpartyReceiverPath: PaykitReceiverPath.server, + billingPeriodStartsAt: startsAt + ) + + XCTAssertNotEqual( + PaykitSubscriptionNotificationIdentifier.identifier(identity: "pubkypayer-a", requestId: requestId), + PaykitSubscriptionNotificationIdentifier.identifier(identity: "pubkypayer-b", requestId: requestId) + ) + } + + func testSubscriptionAcceptanceHistoryDistinguishesRejectedProposalFromCanceledSubscription() throws { + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + let rejectedProposal = try XCTUnwrap(PaykitSubscription(record: paymentRequestRecord( + state: .rejected, + recurrence: recurrence + ))) + let canceledSubscription = try XCTUnwrap(PaykitSubscription(record: paymentRequestRecord( + state: .canceled, + recurrence: recurrence, + acceptedEventId: "accepted-event" + ))) + + XCTAssertFalse(rejectedProposal.wasAccepted) + XCTAssertTrue(canceledSubscription.wasAccepted) + } + + func testCanceledSubscriptionDoesNotRetainNotificationFromStaleSynchronization() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "week", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + let subscription = try XCTUnwrap(PaykitSubscription(record: paymentRequestRecord( + state: .activeRecurring, + recurrence: recurrence + ))) + let center = PaykitSubscriptionNotificationCenterMock() + let scheduler = PaykitSubscriptionNotificationScheduler(center: center) + await center.pauseNextAdd() + + let synchronization = Task { + await scheduler.synchronize( + [subscription], + acceptedAt: [subscription.id: now], + pendingRequestIds: [], + payerIdentity: "pubky\(String(repeating: "z", count: 52))", + notificationsEnabled: true, + now: now + ) + } + try await waitUntil { await center.isAddPaused } + await scheduler.cancel() + await center.resumeAdd() + await synchronization.value + + let pendingIdentifiers = await center.pendingIdentifiers + XCTAssertTrue(pendingIdentifiers.isEmpty) + } + + func testDisablingNotificationsRemovesPendingSubscriptionPeriodNotification() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "week", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + let subscription = try XCTUnwrap(PaykitSubscription(record: paymentRequestRecord( + state: .activeRecurring, + recurrence: recurrence + ))) + let request = try XCTUnwrap(subscription.paymentDueOnAcceptance(at: now)) + let payerIdentity = "pubky\(String(repeating: "z", count: 52))" + let identifier = try XCTUnwrap( + PaykitSubscriptionNotificationIdentifier.identifier(identity: payerIdentity, requestId: request.id) + ) + let center = PaykitSubscriptionNotificationCenterMock() + try await center.add(UNNotificationRequest( + identifier: identifier, + content: UNMutableNotificationContent(), + trigger: nil + )) + let scheduler = PaykitSubscriptionNotificationScheduler(center: center) + + await scheduler.synchronize( + [subscription], + acceptedAt: [subscription.id: now], + pendingRequestIds: [request.id], + payerIdentity: payerIdentity, + notificationsEnabled: false, + now: now + ) + + let pendingIdentifiers = await center.pendingIdentifiers + XCTAssertTrue(pendingIdentifiers.isEmpty) + } + func testContactPaymentContextClaimIsExclusiveAndIdentityBased() { let app = AppViewModel() let first = ContactPaymentContext(publicKey: "pubkycontact") @@ -116,7 +270,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { await manager.refresh() - XCTAssertEqual(manager.pendingRequests.map(\.paymentRequestId), ["incoming"]) + XCTAssertEqual(manager.pendingRequests.map(\.paymentRequestId), ["incoming", "accepted"]) XCTAssertEqual( Set(manager.historyRequests.map(\.paymentRequestId)), Set(["incoming", "accepted", "rejected", "expired", "outgoing", "unsupported"]) @@ -131,6 +285,531 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ) } + func testRefreshMapsActiveRecurringRequestAndCurrentUnpaidPeriod() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + let record = try paymentRequestRecord( + id: "recurring", + state: .activeRecurring, + recurrence: recurrence, + metadata: #"{"note":"Mobile plan","subscription":{"version":1,"description":"10 GB every month","benefits":["Roaming"]}}"# + ) + let manager = paymentRequestManager( + sdk: PaymentRequestSdkMock(records: [record]), + clock: PaymentRequestTestClock(now) + ) + + await manager.refresh() + + let subscription = try XCTUnwrap(manager.subscriptions.first) + XCTAssertEqual(subscription.note, "Mobile plan") + XCTAssertEqual(subscription.metadata.description, "10 GB every month") + XCTAssertEqual(subscription.metadata.benefits, ["Roaming"]) + let request = try XCTUnwrap(manager.pendingRequests.first) + XCTAssertEqual(request.paymentRequestId, "recurring") + XCTAssertFalse(request.requiresAcceptance) + XCTAssertEqual(request.billingPeriod?.startsAt, try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-01T08:00:00Z"))) + XCTAssertEqual(request.billingPeriod?.endsAt, try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-02-01T08:00:00Z"))) + } + + func testEndedSubscriptionKeepsItsUnpaidPeriodAvailable() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: "2027-01-10T08:00:00Z" + ) + let record = try paymentRequestRecord( + state: .activeRecurring, + recurrence: recurrence, + lastEventAt: "2027-01-01T08:00:00Z" + ) + let manager = paymentRequestManager( + sdk: PaymentRequestSdkMock(records: [record]), + clock: PaymentRequestTestClock(now) + ) + + await manager.refresh() + + XCTAssertTrue(try XCTUnwrap(manager.subscriptions.first).isExpired(at: now)) + XCTAssertEqual(manager.pendingRequests.first?.billingPeriod?.endsAt, recurrence.endsAt.flatMap(PaykitPaymentRequest.parseDate)) + } + + func testAcceptingSubscriptionSurfacesCurrentPeriodAndCancelRemovesIt() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord(id: "recurring", recurrence: recurrence)]) + let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) + await manager.refresh() + + let subscription = try XCTUnwrap(manager.subscriptions.first) + let dueRequest = try await manager.accept(subscription) + + XCTAssertEqual(dueRequest?.paymentRequestId, "recurring") + let request = try XCTUnwrap(dueRequest) + XCTAssertFalse(request.requiresAcceptance) + try await manager.prepareForPayment(request) + XCTAssertEqual(manager.pendingRequests, [request]) + XCTAssertTrue(manager.isApprovedForPayment(request)) + await manager.finishPayment(request) + XCTAssertFalse(manager.isApprovedForPayment(request)) + try await manager.cancel(XCTUnwrap(manager.subscriptions.first)) + XCTAssertTrue(manager.subscriptions.isEmpty) + XCTAssertTrue(manager.pendingRequests.isEmpty) + } + + func testInitialSubscriptionPaymentRetryKeepsInitialPaymentSemantics() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + let manager = try paymentRequestManager( + sdk: PaymentRequestSdkMock(records: [paymentRequestRecord(state: .activeRecurring, recurrence: recurrence)]), + clock: PaymentRequestTestClock(now) + ) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + + XCTAssertEqual(manager.paymentRequestForRetry(request.id), request) + XCTAssertTrue(manager.requestPresentation(request, isInitialSubscriptionPayment: true)) + XCTAssertTrue(manager.consumeInitialSubscriptionPayment(request)) + } + + func testAcceptingSubscriptionSelectsPeriodFromMatchingCounterpartyAndPath() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + let first = try paymentRequestRecord(id: "shared", counterparty: "first", recurrence: recurrence) + let second = try paymentRequestRecord( + id: "shared", + counterparty: "second", + counterpartyReceiverPath: PaykitReceiverPath.wallet, + recurrence: recurrence + ) + let manager = paymentRequestManager( + sdk: PaymentRequestSdkMock(records: [first, second]), + clock: PaymentRequestTestClock(now) + ) + await manager.refresh() + + let subscription = try XCTUnwrap(manager.subscriptions.first { $0.counterparty == "second" }) + let acceptedRequest = try await manager.accept(subscription) + let dueRequest = try XCTUnwrap(acceptedRequest) + + XCTAssertEqual(dueRequest.counterparty, "second") + XCTAssertEqual(dueRequest.counterpartyReceiverPath, PaykitReceiverPath.wallet) + } + + func testAcceptingSubscriptionRejectsTermsChangedAfterReview() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord(recurrence: recurrence)]) + let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) + await manager.refresh() + let reviewedSubscription = try XCTUnwrap(manager.subscriptions.first) + + try await sdk.setRecords([paymentRequestRecord(amount: "0.002", recurrence: recurrence)]) + await manager.refresh() + + do { + _ = try await manager.accept(reviewedSubscription) + XCTFail("Expected changed subscription terms to require another review") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestUnavailable) + } + let snapshot = await sdk.snapshot() + XCTAssertTrue(snapshot.acceptedRequests.isEmpty) + } + + func testDismissedSubscriptionPeriodStaysOutOfQueueAfterRefresh() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + let manager = try paymentRequestManager( + sdk: PaymentRequestSdkMock(records: [paymentRequestRecord(state: .activeRecurring, recurrence: recurrence)]), + clock: PaymentRequestTestClock(now) + ) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + + XCTAssertTrue(manager.dismissSubscriptionPayment(request)) + XCTAssertTrue(manager.pendingRequests.isEmpty) + + await manager.refresh() + + XCTAssertTrue(manager.pendingRequests.isEmpty) + } + + func testCompletedSubscriptionPaymentAwaitingProofSubmissionIsNotOfferedAgain() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + let record = try paymentRequestRecord(state: .activeRecurring, recurrence: recurrence) + let subscription = try XCTUnwrap(PaykitSubscription(record: record)) + let request = try XCTUnwrap(subscription.requests(through: now, acceptedAt: now).first) + let manager = paymentRequestManager( + sdk: PaymentRequestSdkMock(records: [record]), + clock: PaymentRequestTestClock(now), + completedPaymentRequestIds: [request.id] + ) + + await manager.refresh() + + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertEqual(manager.historyRequests.first?.id, request.id) + XCTAssertEqual(manager.historyRequests.first?.lifecycleState, .proofSubmitted) + } + + func testInFlightSubscriptionPaymentIsNotOfferedOrMarkedPaid() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + let record = try paymentRequestRecord(state: .activeRecurring, recurrence: recurrence) + let subscription = try XCTUnwrap(PaykitSubscription(record: record)) + let request = try XCTUnwrap(subscription.paymentDueOnAcceptance(at: now)) + let manager = paymentRequestManager( + sdk: PaymentRequestSdkMock(records: [record]), + clock: PaymentRequestTestClock(now), + inFlightPaymentRequestIds: [request.id] + ) + + await manager.refresh() + + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertTrue(manager.historyRequests.isEmpty) + } + + func testSubscriptionCannotBeCanceledWhilePaymentProofIsPending() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + let record = try paymentRequestRecord(state: .activeRecurring, recurrence: recurrence) + let subscription = try XCTUnwrap(PaykitSubscription(record: record)) + let request = try XCTUnwrap(subscription.paymentDueOnAcceptance(at: now)) + let manager = paymentRequestManager( + sdk: PaymentRequestSdkMock(records: [record]), + clock: PaymentRequestTestClock(now), + protectedRequestIdsForSubscriptionCancellation: [request.id] + ) + + await manager.refresh() + do { + try await manager.cancel(XCTUnwrap(manager.subscriptions.first)) + XCTFail("Expected cancellation to wait for the pending proof") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .operationInProgress) + } + + XCTAssertEqual(manager.subscriptions.count, 1) + } + + func testSubscriptionCancellationDoesNotUseStaleIdentityAfterClear() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + let record = try paymentRequestRecord(state: .activeRecurring, recurrence: recurrence) + let sdk = PaymentRequestSdkMock(records: [record]) + let gate = PaymentProofProtectionGate() + let manager = PaykitPaymentRequestManager( + service: PaykitPaymentRequestService(sdk: sdk, now: { now }, logWarning: { _ in }), + presentationStore: PaymentRequestPresentationMemoryStore(), + subscriptionStateStore: PaymentRequestSubscriptionStateMemoryStore(), + protectedRequestIdsForSubscriptionCancellation: { _, _ in await gate.wait() }, + now: { now }, + isAvailable: { true }, + logWarning: { _ in } + ) + manager.activate(identity: "pubky\(String(repeating: "z", count: 52))") + await manager.refresh() + let subscription = try XCTUnwrap(manager.subscriptions.first) + + let cancellation = Task { try await manager.cancel(subscription) } + try await waitUntil { await gate.isWaiting } + manager.clear() + manager.activate(identity: "pubky\(String(repeating: "a", count: 52))") + await gate.resume() + try await cancellation.value + + let remainingRecords = await sdk.paymentRequests() + XCTAssertEqual(remainingRecords.count, 1) + } + + func testActiveSubscriptionTransitionIncludesPeriodAndMonthBoundaries() throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try XCTUnwrap(TimeZone(secondsFromGMT: 0)) + let weekly = PaymentRequestRecurrence( + every: 1, + unit: "week", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + let yearly = PaymentRequestRecurrence( + every: 1, + unit: "year", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + let weeklySubscription = try XCTUnwrap(PaykitSubscription(record: paymentRequestRecord(state: .activeRecurring, recurrence: weekly))) + let yearlySubscription = try XCTUnwrap(PaykitSubscription(record: paymentRequestRecord(state: .activeRecurring, recurrence: yearly))) + + XCTAssertEqual( + subscriptionNextTransitionDate(subscriptions: [weeklySubscription], now: now, calendar: calendar), + ISO8601DateFormatter().date(from: "2027-01-22T08:00:00Z") + ) + XCTAssertEqual( + subscriptionNextTransitionDate(subscriptions: [yearlySubscription], now: now, calendar: calendar), + ISO8601DateFormatter().date(from: "2027-02-01T00:00:00Z") + ) + } + + func testCommittedSubscriptionAcceptanceSurvivesImmediateRefreshFailure() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord(id: "recurring", recurrence: recurrence)]) + let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) + await manager.refresh() + await sdk.setReceiveError(.receive) + + let request = try await manager.accept(XCTUnwrap(manager.subscriptions.first)) + + XCTAssertEqual(manager.subscriptions.first?.lifecycleState, .activeRecurring) + XCTAssertEqual(request, manager.pendingRequests.first) + } + + func testSubscriptionAcceptanceCompletionAfterClearDoesNotRepopulateManager() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord(id: "recurring", recurrence: recurrence)]) + let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) + await manager.refresh() + await sdk.pauseNextAccept() + + let acceptance = Task { + try await manager.accept(XCTUnwrap(manager.subscriptions.first)) + } + try await waitUntil { await sdk.acceptIsPaused() } + manager.clear() + await sdk.resumeAccept() + + let dueRequest = try await acceptance.value + XCTAssertNil(dueRequest) + XCTAssertTrue(manager.subscriptions.isEmpty) + XCTAssertTrue(manager.pendingRequests.isEmpty) + } + + func testMonthlyRecurrenceKeepsAnchorDayAfterShortMonth() throws { + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-31T08:00:00Z", + anchor: "2027-01-31T08:00:00Z", + endsAt: nil + ) + let schedule = try XCTUnwrap(PaykitSubscriptionRecurrence(recurrence)) + let acceptedAt = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-31T08:00:00Z")) + let through = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-03-15T08:00:00Z")) + + let periods = schedule.periods(through: through, acceptedAt: acceptedAt) + + XCTAssertEqual(periods.count, 2) + XCTAssertEqual(periods[0].endsAt, try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-02-28T08:00:00Z"))) + XCTAssertEqual(periods[1].endsAt, try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-03-31T08:00:00Z"))) + } + + func testRecurrenceUsesFirstAnchorBoundaryAfterStart() throws { + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-15T08:00:00Z", + endsAt: nil + ) + let schedule = try XCTUnwrap(PaykitSubscriptionRecurrence(recurrence)) + let acceptedAt = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-01T08:00:00Z")) + let through = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-10T08:00:00Z")) + + let period = try XCTUnwrap(schedule.periods(through: through, acceptedAt: acceptedAt).first) + + XCTAssertEqual(period.startsAt, acceptedAt) + XCTAssertEqual(period.endsAt, try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z"))) + } + + func testRecurrenceReturnsConsecutiveUpcomingPeriods() throws { + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "week", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + let schedule = try XCTUnwrap(PaykitSubscriptionRecurrence(recurrence)) + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-02T08:00:00Z")) + + let periods = schedule.upcomingPeriods(after: now, limit: 3) + + XCTAssertEqual(periods.map(\.startsAt), try [ + XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-08T08:00:00Z")), + XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")), + XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-22T08:00:00Z")), + ]) + } + + func testRecurrencePreservesNanosecondBillingBoundaries() throws { + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "day", + startsAt: "2027-01-01T08:00:00.123100Z", + anchor: "2027-01-01T08:00:00.123900Z", + endsAt: nil + ) + let schedule = try XCTUnwrap(PaykitSubscriptionRecurrence(recurrence)) + let through = try XCTUnwrap(PaykitPaymentRequest.parseDate("2027-01-01T08:00:01Z")) + let acceptedAt = try XCTUnwrap(PaykitPaymentRequest.parseDate("2027-01-01T08:00:00Z")) + + let period = try XCTUnwrap(schedule.periods(through: through, acceptedAt: acceptedAt).first) + + XCTAssertEqual(period.sdkValue.startsAt, "2027-01-01T08:00:00.123100Z") + XCTAssertEqual(period.sdkValue.endsAt, "2027-01-01T08:00:00.123900Z") + } + + func testSubscriptionTimestampUsesCanonicalInstantPrecision() { + XCTAssertEqual(PaykitSubscriptionTimestamp.canonical("2027-01-01T08:00:00.1Z"), "2027-01-01T08:00:00.100Z") + XCTAssertEqual(PaykitSubscriptionTimestamp.canonical("2027-01-01T08:00:00.1000Z"), "2027-01-01T08:00:00.100Z") + XCTAssertEqual( + PaykitSubscriptionTimestamp.canonical("2027-01-01T08:00:00.123456789Z"), + "2027-01-01T08:00:00.123456789Z" + ) + } + + func testRecurrenceDoesNotInventPeriodWhenAnchorSearchExceedsLimit() throws { + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "day", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2077-01-01T08:00:00Z", + endsAt: nil + ) + let schedule = try XCTUnwrap(PaykitSubscriptionRecurrence(recurrence)) + let start = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-01T08:00:00Z")) + + XCTAssertTrue(schedule.periods(through: start, acceptedAt: start).isEmpty) + XCTAssertFalse(schedule.canMaterializePeriods) + } + + func testRecurringProposalRejectsMalformedExpiryAndDisablesUnsupportedPaymentDetails() async throws { + let expiration = Date(timeIntervalSince1970: 1_800_000_000) + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + let endedRecurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: timestamp(expiration) + ) + let manager = try paymentRequestManager( + sdk: PaymentRequestSdkMock(records: [ + paymentRequestRecord(id: "malformed", expiresAt: "not-a-timestamp", recurrence: recurrence), + paymentRequestRecord(id: "unsupported", recurrence: recurrence, endpoints: ["btc-unsupported-method"]), + paymentRequestRecord(id: "ended", recurrence: endedRecurrence), + ]), + clock: PaymentRequestTestClock(expiration) + ) + + await manager.refresh() + + let subscription = try XCTUnwrap(manager.subscriptions.first) + XCTAssertEqual(subscription.paymentRequestId, "unsupported") + XCTAssertFalse(subscription.isProposalActionable(at: Date(timeIntervalSince1970: 1_800_000_000))) + XCTAssertEqual(manager.subscriptionProposalForPresentation()?.id, subscription.id) + XCTAssertEqual( + manager.subscriptions.first { $0.paymentRequestId == "ended" }?.lifecycleState, + .proposalExpired + ) + XCTAssertFalse(manager.subscriptions.first { $0.paymentRequestId == "ended" }?.isProposalActionable(at: expiration) ?? true) + + let expiring = try XCTUnwrap(PaykitSubscription(record: paymentRequestRecord( + id: "expiring", + expiresAt: timestamp(expiration), + recurrence: recurrence + ))) + XCTAssertEqual(expiring.withExpiredLifecycle(at: expiration).lifecycleState, .proposalExpired) + } + func testRefreshRejectsAmountsOutsideTheAppPaymentRange() async throws { let records = try [ paymentRequestRecord(id: "one-sat", amount: "0.00000001"), @@ -195,9 +874,12 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let now: @Sendable () -> Date = { Date() } let manager = PaykitPaymentRequestManager( service: PaykitPaymentRequestService(sdk: sdk, now: now, logWarning: { _ in }), + subscriptionStateStore: PaymentRequestSubscriptionStateMemoryStore(), + completedPaymentRequestIds: { _ in [] }, now: now, logWarning: { _ in } ) + manager.activate(identity: "pubky\(String(repeating: "z", count: 52))") await manager.refresh() XCTAssertEqual(manager.pendingRequests.count, 1) @@ -463,7 +1145,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { try await task.value } - func testAcceptedRequestStaysApprovedUntilSendFlowFinishes() async throws { + func testAcceptedRequestRemainsRetryableAfterSendFlowFinishes() async throws { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) let manager = paymentRequestManager(sdk: sdk) await manager.refresh() @@ -472,8 +1154,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { try await manager.prepareForPayment(request) XCTAssertTrue(manager.isApprovedForPayment(request)) - manager.finishPayment(request) + await manager.finishPayment(request) XCTAssertFalse(manager.isApprovedForPayment(request)) + XCTAssertEqual(manager.pendingRequests, [request.updatingLifecycleState(.accepted)]) } func testRefreshKeepsRequestVisibleWhileAcceptanceIsFinishing() async throws { @@ -659,9 +1342,11 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let identity = "pubky\(String(repeating: "y", count: 52))" let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) let store = PaymentRequestPresentationMemoryStore() + let subscriptionStore = PaymentRequestSubscriptionStateMemoryStore() let firstManager = PaykitPaymentRequestManager( service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), presentationStore: store, + subscriptionStateStore: subscriptionStore, logWarning: { _ in } ) firstManager.activate(identity: identity) @@ -672,6 +1357,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let restoredManager = PaykitPaymentRequestManager( service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), presentationStore: store, + subscriptionStateStore: subscriptionStore, logWarning: { _ in } ) restoredManager.activate(identity: identity) @@ -683,6 +1369,44 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(restoredManager.requestsForPresentation(), [request]) } + func testPresentedSubscriptionStaysAvailableWithoutAutoPresentingAfterManagerRecreation() async throws { + let identity = "pubky\(String(repeating: "y", count: 52))" + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord(id: "subscription", recurrence: recurrence)]) + let presentationStore = PaymentRequestPresentationMemoryStore() + let subscriptionStore = PaymentRequestSubscriptionStateMemoryStore() + let firstManager = PaykitPaymentRequestManager( + service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), + presentationStore: presentationStore, + subscriptionStateStore: subscriptionStore, + logWarning: { _ in } + ) + firstManager.activate(identity: identity) + await firstManager.refresh() + let subscription = try XCTUnwrap(firstManager.subscriptionProposalForPresentation()) + firstManager.markSubscriptionProposalPresented(subscription) + + let restoredManager = PaykitPaymentRequestManager( + service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), + presentationStore: presentationStore, + subscriptionStateStore: subscriptionStore, + logWarning: { _ in } + ) + restoredManager.activate(identity: identity) + await restoredManager.refresh() + + let restoredSubscription = try XCTUnwrap(restoredManager.subscriptions.first) + XCTAssertNil(restoredManager.subscriptionProposalForPresentation()) + restoredManager.requestSubscriptionPresentation(restoredSubscription) + XCTAssertEqual(restoredManager.subscriptionProposalForPresentation(), restoredSubscription) + } + func testRejectRemovesOnlyMatchingRequestAndQueuesResponse() async throws { let firstRecord = try paymentRequestRecord(id: "first") let secondRecord = try paymentRequestRecord(id: "second") @@ -1071,6 +1795,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let manager = PaykitPaymentRequestManager( service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), presentationStore: store, + subscriptionStateStore: PaymentRequestSubscriptionStateMemoryStore(), isAvailable: { true }, logWarning: { _ in } ) @@ -1094,7 +1819,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { private func paymentRequestManager( sdk: PaymentRequestSdkMock, clock: PaymentRequestTestClock = PaymentRequestTestClock(Date()), - isPrivatePaymentPublishingEnabled: Bool = true + isPrivatePaymentPublishingEnabled: Bool = true, + completedPaymentRequestIds: Set = [], + inFlightPaymentRequestIds: Set = [], + protectedRequestIdsForSubscriptionCancellation: Set = [] ) -> PaykitPaymentRequestManager { let now: @Sendable () -> Date = { clock.now() } let manager = PaykitPaymentRequestManager( @@ -1105,6 +1833,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { logWarning: { _ in } ), presentationStore: PaymentRequestPresentationMemoryStore(), + subscriptionStateStore: PaymentRequestSubscriptionStateMemoryStore(), + completedPaymentRequestIds: { _ in completedPaymentRequestIds }, + inFlightPaymentRequestIds: { _ in inFlightPaymentRequestIds }, + protectedRequestIdsForSubscriptionCancellation: { _, _ in protectedRequestIdsForSubscriptionCancellation }, now: now, isAvailable: { true }, logWarning: { _ in } @@ -1125,8 +1857,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { recurrence: PaymentRequestRecurrence? = nil, endpoints: [String] = [PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue], metadata: String = "{}", + lastEventAt: String = "2027-01-15T08:00:00Z", proposalOutboundMessageId: UInt64? = nil, - proposalOutboundStatus: OutboundPrivateMessageStatus? = nil + proposalOutboundStatus: OutboundPrivateMessageStatus? = nil, + acceptedEventId: String? = nil ) throws -> PaymentRequestRecord { try PaymentRequestRecord( counterparty: counterparty, @@ -1146,7 +1880,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { acceptedPaymentEndpointIdentifiers: endpoints, metadata: PrivateJsonObject(text: metadata) ), - acceptedEventId: nil, + acceptedEventId: acceptedEventId, acceptedOutboundStatus: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, @@ -1156,7 +1890,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { lastStreamItemId: 1, lastOutboundMessageId: nil, lastOutboundStatus: nil, - lastEventAt: "2027-01-15T08:00:00Z", + lastEventAt: lastEventAt, invalidReason: nil ) } @@ -1196,6 +1930,18 @@ private final class PaymentRequestPresentationMemoryStore: PaykitPaymentRequestP } } +private final class PaymentRequestSubscriptionStateMemoryStore: PaykitSubscriptionStateStoring { + private var states: [String: PaykitSubscriptionState] = [:] + + func load(identity: String) -> PaykitSubscriptionState { + states[identity] ?? PaykitSubscriptionState() + } + + func save(_ subscriptionState: PaykitSubscriptionState, identity: String) { + states[identity] = subscriptionState + } +} + private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { private var activeIdentity = "pubky\(String(repeating: "z", count: 52))" private var records: [PaymentRequestRecord] @@ -1335,11 +2081,22 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { isAcceptPaused = false } - let record = try removeRecord( - counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, - id: paymentRequestId - ) + let record: PaymentRequestRecord + if let index = records.firstIndex(where: { + $0.counterparty == counterparty && + $0.counterpartyReceiverPath == counterpartyReceiverPath && + $0.paymentRequestId == paymentRequestId && + $0.terms?.recurrence != nil + }) { + records[index].state = .activeRecurring + record = records[index] + } else { + record = try removeRecord( + counterparty: counterparty, + counterpartyReceiverPath: counterpartyReceiverPath, + id: paymentRequestId + ) + } if acceptFailuresAfterRemoval > 0 { acceptFailuresAfterRemoval -= 1 throw PaymentRequestSdkMockError.process @@ -1375,6 +2132,19 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { return record } + func cancelPaymentRequest( + counterparty: String, + counterpartyReceiverPath: String, + paymentRequestId: String, + reason _: String? + ) throws -> PaymentRequestRecord { + try removeRecord( + counterparty: counterparty, + counterpartyReceiverPath: counterpartyReceiverPath, + id: paymentRequestId + ) + } + func failNextProcess() { processFailuresRemaining += 1 } @@ -1571,6 +2341,65 @@ private enum PaymentRequestTestError: Error { case timedOut } +private actor PaymentProofProtectionGate { + private var continuation: CheckedContinuation? + + var isWaiting: Bool { + continuation != nil + } + + func wait() async -> Set { + await withCheckedContinuation { continuation = $0 } + return [] + } + + func resume() { + continuation?.resume() + continuation = nil + } +} + +private actor PaykitSubscriptionNotificationCenterMock: PaykitSubscriptionNotificationCenter { + private var requests: [String: UNNotificationRequest] = [:] + private var shouldPauseNextAdd = false + private var addContinuation: CheckedContinuation? + + var isAddPaused: Bool { + addContinuation != nil + } + + var pendingIdentifiers: Set { + Set(requests.keys) + } + + func pauseNextAdd() { + shouldPauseNextAdd = true + } + + func pendingNotificationRequests() -> [UNNotificationRequest] { + Array(requests.values) + } + + func add(_ request: UNNotificationRequest) async throws { + if shouldPauseNextAdd { + shouldPauseNextAdd = false + await withCheckedContinuation { addContinuation = $0 } + } + requests[request.identifier] = request + } + + func removePendingNotificationRequests(withIdentifiers identifiers: [String]) { + for identifier in identifiers { + requests.removeValue(forKey: identifier) + } + } + + func resumeAdd() { + addContinuation?.resume() + addContinuation = nil + } +} + @MainActor private func waitUntil( timeout: Duration = .seconds(2), diff --git a/changelog.d/next/paykit-subscriptions.added.md b/changelog.d/next/paykit-subscriptions.added.md new file mode 100644 index 000000000..71b66fce6 --- /dev/null +++ b/changelog.d/next/paykit-subscriptions.added.md @@ -0,0 +1 @@ +Bitkit can now review, manage, and pay recurring payment requests from Paykit contacts. From 6d86b8273ed6b96f221c76761a9e0f03d17b1740 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 26 Aug 2026 13:17:33 -0500 Subject: [PATCH 2/2] chore: name subscription changelog fragment --- changelog.d/next/{paykit-subscriptions.added.md => 685.added.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{paykit-subscriptions.added.md => 685.added.md} (100%) diff --git a/changelog.d/next/paykit-subscriptions.added.md b/changelog.d/next/685.added.md similarity index 100% rename from changelog.d/next/paykit-subscriptions.added.md rename to changelog.d/next/685.added.md