diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 9c5c18dab..7d46448d7 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 @@ -257,11 +279,15 @@ struct AppScene: View { .onChange(of: paykitPaymentRequestManager.pendingRequests) { _, requests in guard let request = app.contactPaymentContext?.incomingPaymentRequest, !requests.contains(where: { $0.id == request.id }), - !paykitPaymentRequestManager.isApprovedForPayment(request), - sheets.activeSheetConfiguration?.id == .send + !paykitPaymentRequestManager.isApprovedForPayment(request) + else { return } + + let activeSheetId = sheets.activeSheetConfiguration?.id + guard activeSheetId == .send || + (activeSheetId == .subscription && app.contactPaymentContext?.isInitialSubscriptionPayment == true) else { return } - sheets.hideSheetIfActive(.send, reason: "Incoming payment request is no longer available") + sheets.hideSheet(reason: "Incoming payment request is no longer available") } .onChange(of: navigation.currentRoute) { oldRoute, newRoute in guard shouldDiscardPendingImport(currentRoute: oldRoute, destination: newRoute) else { @@ -761,7 +787,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 +800,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 +897,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 +978,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 000000000..2633f499b Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/subscription-clock.imageset/subscription-clock.png differ 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..8b5128f9c 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,8 +191,6 @@ struct MainNavView: View { .sheet( item: $sheets.sendSheetItem, onDismiss: { - app.resetSendState() - wallet.resetSendState(speed: settings.defaultTransactionSpeed) sheets.hideSheetIfActive(.send, reason: "Send sheet dismissed") } ) { @@ -288,6 +295,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 +550,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..2ee9d694c 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -1,3 +1,4 @@ +import Combine import CryptoKit import Foundation import LDKNode @@ -6,6 +7,17 @@ import Paykit enum PaykitPaymentProofKind: String, Codable { case lightning = "bitcoin-bolt11-preimage" case onchain = "bitcoin-onchain-txid" + + init?(paymentEndpointIdentifier: String) { + guard let method = PublicPaykitService.MethodId(rawValue: paymentEndpointIdentifier) else { return nil } + self = method.onchainNetwork == nil ? .lightning : .onchain + } +} + +struct PaykitOnchainPaymentResolution: Equatable { + let identity: String + let requestId: PaykitPaymentRequest.ID + let transactionId: String } struct PendingPaykitPaymentProof: Codable, Equatable { @@ -13,8 +25,39 @@ struct PendingPaykitPaymentProof: Codable, Equatable { 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 +128,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 +189,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 +200,7 @@ actor PaykitPaymentProofService { self.sdk = sdk self.store = store self.lightningPaymentLookup = lightningPaymentLookup + self.onchainPaymentLookup = onchainPaymentLookup self.logInfo = logInfo self.logWarning = logWarning } @@ -124,9 +217,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 +255,7 @@ actor PaykitPaymentProofService { requestId: request.id, paymentEndpointIdentifier: paymentEndpointIdentifier, kind: kind, + billingPeriod: request.billingPeriod, paymentIdentifier: nil, proofData: nil ) @@ -164,17 +266,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 +340,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 +369,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 +405,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 +444,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 +472,89 @@ actor PaykitPaymentProofService { } } - private func submit(_ pendingProof: PendingPaykitPaymentProof) async { - guard let proofData = pendingProof.proofData else { return } + func completedRequestProofKindsAwaitingSubmission(identity: String) async -> [PaykitPaymentRequest.ID: PaykitPaymentProofKind] { + do { + return try await loadProofs().reduce(into: [:]) { result, proof in + guard PubkyPublicKeyFormat.matches(proof.identity, identity), proof.proofData != nil else { return } + result[proof.requestId] = proof.kind + } + } 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 +565,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 +578,10 @@ actor PaykitPaymentProofService { } } await removeRequestProofs(pendingProof) + return true } catch { logWarning("Failed to queue a Paykit payment proof: \(error)") + return false } } @@ -345,13 +597,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 +635,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 +688,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..5cce81e3a 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,19 @@ struct PaykitPaymentRequest: Identifiable, Hashable { let deliveryStatus: DeliveryStatus? let direction: Direction let lifecycleState: Paykit.PaymentRequestLifecycleState + let billingPeriod: PaykitBillingPeriod? + let paymentProofKind: PaykitPaymentProofKind? + + var requiresAcceptance: Bool { + billingPeriod == nil && lifecycleState == .proposed + } var id: ID { ID( paymentRequestId: paymentRequestId, counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath + counterpartyReceiverPath: counterpartyReceiverPath, + billingPeriodStartsAt: billingPeriod?.startsAt ) } @@ -68,7 +88,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 +102,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 +123,10 @@ struct PaykitPaymentRequest: Identifiable, Hashable { deliveryStatus = expectedRole == .payee ? Self.deliveryStatus(from: record.proposalOutboundStatus) : nil direction = expectedRole == .payer ? .incoming : .outgoing lifecycleState = record.state + billingPeriod = nil + paymentProofKind = record.paymentProofs.last.flatMap { + PaykitPaymentProofKind(paymentEndpointIdentifier: $0.paymentEndpointIdentifier) + } } init( @@ -126,9 +150,14 @@ struct PaykitPaymentRequest: Identifiable, Hashable { self.deliveryStatus = deliveryStatus direction = .outgoing lifecycleState = .proposed + billingPeriod = nil + paymentProofKind = nil } - func updatingLifecycleState(_ state: Paykit.PaymentRequestLifecycleState) -> PaykitPaymentRequest { + func updatingLifecycleState( + _ state: Paykit.PaymentRequestLifecycleState, + paymentProofKind: PaykitPaymentProofKind? = nil + ) -> PaykitPaymentRequest { PaykitPaymentRequest( paymentRequestId: paymentRequestId, counterparty: counterparty, @@ -141,10 +170,34 @@ struct PaykitPaymentRequest: Identifiable, Hashable { acceptedPaymentEndpointIdentifiers: acceptedPaymentEndpointIdentifiers, deliveryStatus: deliveryStatus, direction: direction, - lifecycleState: state + lifecycleState: state, + billingPeriod: billingPeriod, + paymentProofKind: paymentProofKind ?? self.paymentProofKind ) } + init( + subscription: PaykitSubscription, + billingPeriod: PaykitBillingPeriod, + lifecycleState: Paykit.PaymentRequestLifecycleState, + paymentProofKind: PaykitPaymentProofKind? = nil + ) { + 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 + self.paymentProofKind = paymentProofKind + } + private init( paymentRequestId: String, counterparty: String, @@ -157,7 +210,9 @@ struct PaykitPaymentRequest: Identifiable, Hashable { acceptedPaymentEndpointIdentifiers: [String], deliveryStatus: DeliveryStatus?, direction: Direction, - lifecycleState: Paykit.PaymentRequestLifecycleState + lifecycleState: Paykit.PaymentRequestLifecycleState, + billingPeriod: PaykitBillingPeriod?, + paymentProofKind: PaykitPaymentProofKind? ) { self.paymentRequestId = paymentRequestId self.counterparty = counterparty @@ -171,10 +226,12 @@ struct PaykitPaymentRequest: Identifiable, Hashable { self.deliveryStatus = deliveryStatus self.direction = direction self.lifecycleState = lifecycleState + self.billingPeriod = billingPeriod + self.paymentProofKind = paymentProofKind } 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 +244,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 +266,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 +289,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 +299,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 +335,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 +392,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 +436,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 +557,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 +705,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 completedPaymentProofKinds: @Sendable (String) async -> [PaykitPaymentRequest.ID: PaykitPaymentProofKind] + 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 +744,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(), + completedPaymentProofKinds: @escaping @Sendable (String) async -> [PaykitPaymentRequest.ID: PaykitPaymentProofKind] = { identity in + await PaykitPaymentProofService.shared.completedRequestProofKindsAwaitingSubmission(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 +789,11 @@ final class PaykitPaymentRequestManager { ) { self.service = service ?? PaykitPaymentRequestService(now: now, logWarning: logWarning) self.presentationStore = presentationStore + self.subscriptionStateStore = subscriptionStateStore + self.subscriptionNotificationScheduler = subscriptionNotificationScheduler + self.completedPaymentProofKinds = completedPaymentProofKinds + self.inFlightPaymentRequestIds = inFlightPaymentRequestIds + self.protectedRequestIdsForSubscriptionCancellation = protectedRequestIdsForSubscriptionCancellation self.now = now self.isAvailable = isAvailable self.logWarning = logWarning @@ -637,6 +814,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 +908,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 +944,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 +972,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 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 requestPresentation(_ request: PaykitPaymentRequest) -> Bool { + 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 +1127,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 +1151,11 @@ final class PaykitPaymentRequestManager { presentationRetryTask = nil pendingRequests = [] historyRequests = [] + subscriptions = [] eligibleTargets = [] processingRequestIds = [] approvedPaymentRequestIds = [] + initialSubscriptionPaymentRequestIds = [] activeIdentity = nil savedPublicKeys = [] presentedRequestIds = [] @@ -799,7 +1163,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 +1227,38 @@ 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 = completedPaymentProofKinds(activeIdentity) + async let inFlight = inFlightPaymentRequestIds(activeIdentity) + let (completedProofKinds, inFlightRequestIds) = await (completed, inFlight) + let protectedRequestIds = Set(completedProofKinds.keys).union(inFlightRequestIds) + 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 +1311,86 @@ 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 completedProofKinds = completedPaymentProofKinds(activeIdentity) + async let inFlightRequestIds = inFlightPaymentRequestIds(activeIdentity) + let (locallyCompletedProofKinds, locallyInFlightRequestIds) = await (completedProofKinds, inFlightRequestIds) + let locallyCompletedRequestIds = Set(locallyCompletedProofKinds.keys) + 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 + } + guard let proofKind = locallyCompletedProofKinds[request.id] else { return nil } + return request.updatingLifecycleState(.proofSubmitted, paymentProofKind: proofKind) + } + } 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) + } + let oneTimeHistory = snapshot.history.map { request in + guard let proofKind = locallyCompletedProofKinds[request.id] else { return request } + return request.updatingLifecycleState(.proofSubmitted, paymentProofKind: proofKind) + } + pendingRequests = recurringPending + oneTimePending for request in protectedRequests where !pendingRequests.contains(where: { $0.id == request.id }) { pendingRequests.append(request) } - historyRequests = snapshot.history.sorted { + historyRequests = (oneTimeHistory + 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 +1410,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 +1464,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 +1502,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 +1549,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 +1568,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..4a57f9a83 --- /dev/null +++ b/Bitkit/Services/PaykitSubscription.swift @@ -0,0 +1,720 @@ +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 + } + + struct Payment: Hashable { + let billingPeriod: PaykitBillingPeriod + let proofKind: PaykitPaymentProofKind? + } + + 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 payments: [Payment] + + var paidPeriods: [PaykitBillingPeriod] { + payments.map(\.billingPeriod) + } + + 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 + payments = record.paymentProofs.compactMap { proof in + guard let billingPeriod = proof.billingPeriod.flatMap(PaykitBillingPeriod.init) else { return nil } + return Payment( + billingPeriod: billingPeriod, + proofKind: PaykitPaymentProofKind(paymentEndpointIdentifier: proof.paymentEndpointIdentifier) + ) + } + } + + func requests(through date: Date, acceptedAt: Date) -> [PaykitPaymentRequest] { + recurrence.periods(through: date, acceptedAt: acceptedAt).map { period in + let payment = payments.last { $0.billingPeriod == period } + return PaykitPaymentRequest( + subscription: self, + billingPeriod: period, + lifecycleState: payment == nil ? .activeRecurring : .proofSubmitted, + paymentProofKind: payment?.proofKind + ) + } + } + + 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..bfaad7929 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? { @@ -792,6 +795,10 @@ extension AppViewModel { contactPaymentContext?.id == context.id } + var hasSendPaymentTarget: Bool { + scannedLightningInvoice != nil || scannedOnchainInvoice != nil || lnurlPayData != nil + } + func resetSendState(preservingContactPaymentContext: Bool = false) { scannedLightningInvoice = nil scannedOnchainInvoice = nil @@ -1059,18 +1066,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..d61a813dd 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: request.paymentRailColors.icon, + backgroundColor: request.paymentRailColors.background, + 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,63 +428,260 @@ 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) + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .autoupdatingCurrent + formatter.setLocalizedDateFormatFromTemplate("MMMMd") + return formatter + }() + + private func dismiss(_ request: PaykitPaymentRequest) async { + do { + 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 { + let isCompleted = request.lifecycleState == .proofSubmitted + let icon = if isCompleted { + request.direction == .incoming ? "arrow-up" : "arrow-down" + } else { + request.direction == .incoming ? "arrow-down" : "arrow-up" + } + let colors: (icon: Color, background: Color) = if isCompleted { + request.paymentRailColors + } else { + request.direction == .incoming + ? (.purpleAccent, .purple16) + : (.brandAccent, .brand16) + } + + return 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: icon, + iconColor: colors.icon, + backgroundColor: colors.background, + size: 48 + ) + } } - return contact.displayName } - private static let dateTimeFormatter: DateFormatter = { + 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("MMMMdHm") + formatter.setLocalizedDateFormatFromTemplate("MMMMd") return formatter }() - private static let dateFormatter: DateFormatter = { + private static let timeFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = .autoupdatingCurrent - formatter.setLocalizedDateFormatFromTemplate("MMMMd") + formatter.setLocalizedDateFormatFromTemplate("Hm") return formatter }() +} - private func reject(_ request: PaykitPaymentRequest) async { - do { - try await paymentRequests.reject(request) - } catch { - app.toast(error) - } +private extension PaykitPaymentRequest { + var paymentRailColors: (icon: Color, background: Color) { + paymentProofKind == .lightning + ? (.purpleAccent, .purple16) + : (.brandAccent, .brand16) } } diff --git a/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift new file mode 100644 index 000000000..bf41aaff1 --- /dev/null +++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift @@ -0,0 +1,916 @@ +import Lottie +import SwiftUI + +struct SubscriptionSheetItem: SheetItem { + enum Route: Hashable { + case review(PaykitSubscription) + case success + case details(PaykitSubscription) + case cancel(PaykitSubscription) + case payment(SendRoute) + } + + 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() + @State private var isAccepting = false + + 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) + case let .payment(sendRoute): + SendSheet(config: SendSheetItem(initialRoute: sendRoute), isEmbedded: true) + } + } + .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 !isAccepting, + !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() + } + .interactiveDismissDisabled(isAccepting) + } + + 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) { + guard !isAccepting else { return } + previousRoute = route + route = .details(subscription) + } + .allowsHitTesting(!isAccepting) + + 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: isAccepting || 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 !isAccepting else { throw PaykitPaymentRequestError.operationInProgress } + isAccepting = true + defer { isAccepting = false } + + 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 + } + + guard sheets.activeSheetConfiguration?.id == .subscription else { 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 + } + guard sheets.activeSheetConfiguration?.id == .subscription else { 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 { + guard sheets.activeSheetConfiguration?.id == .subscription, + app.ownsContactPaymentContext(context) + else { + if app.ownsContactPaymentContext(context) { + app.resetSendState() + } + return + } + try showInitialPaymentFailure(dueRequest, error: error, context: context) + return + } + guard sheets.activeSheetConfiguration?.id == .subscription, + app.ownsContactPaymentContext(context) + else { + if app.ownsContactPaymentContext(context) { + app.resetSendState() + } + return + } + guard app.hasSendPaymentTarget else { + try showInitialPaymentFailure( + dueRequest, + error: PaykitPaymentRequestError.requestUnavailable, + context: context + ) + return + } + + let sendRoute: SendRoute = app.lnurlPayData == nil ? .confirm : .lnurlPayConfirm + route = .payment(sendRoute) + } + + @MainActor + private func showInitialPaymentFailure( + _ request: PaykitPaymentRequest, + error: Error, + context existingContext: ContactPaymentContext? = nil + ) throws { + guard sheets.activeSheetConfiguration?.id == .subscription else { return } + let context = existingContext ?? ContactPaymentContext( + publicKey: request.counterparty, + incomingPaymentRequest: request, + isInitialSubscriptionPayment: true + ) + guard app.ownsContactPaymentContext(context) || app.claimContactPaymentContext(context) else { + throw PaykitPaymentRequestError.operationInProgress + } + let failure = SendFailureContext( + error: error, + retryRoute: .confirm, + contactPaymentContext: context + ) + route = .payment(.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 { + private let paymentProofKind: PaykitPaymentProofKind? + let onClose: () -> Void + + init(paymentProofKind: PaykitPaymentProofKind? = nil, onClose: @escaping () -> Void) { + self.paymentProofKind = paymentProofKind + self.onClose = onClose + } + + private var confettiAnimation: LottieAnimation? { + let animationName = paymentProofKind == .onchain ? "confetti-orange" : "confetti-purple" + guard let url = Bundle.main.url(forResource: animationName, withExtension: "json") else { return nil } + return LottieAnimation.filepath(url.path) + } + + var body: some View { + ZStack { + if let animation = 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..f83e91991 100644 --- a/Bitkit/Views/Wallets/Send/SendFailure.swift +++ b/Bitkit/Views/Wallets/Send/SendFailure.swift @@ -64,9 +64,13 @@ struct SendFailure: View { @EnvironmentObject var wallet: WalletViewModel let context: SendFailureContext - let onRetryReady: (Bool) -> Void + let onRetryReady: (Bool) async -> 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() @@ -125,11 +133,6 @@ struct SendFailure: View { } private func retryPayment() { - guard context.resetRoutingCachesOnRetry else { - onRetryReady(false) - return - } - guard !wallet.isRetryingLightningPayment else { return } wallet.isRetryingLightningPayment = true @@ -139,22 +142,25 @@ struct SendFailure: View { } do { - var cacheResetError: Error? - do { - try await wallet.resetPaymentRoutingCaches() - } catch { - cacheResetError = error - } + if context.resetRoutingCachesOnRetry { + var cacheResetError: Error? + do { + try await wallet.resetPaymentRoutingCaches() + } catch { + cacheResetError = error + } + + try await wallet.start() + let refreshStartedAt = Date() - try await wallet.start() - let refreshStartedAt = Date() + if let cacheResetError { + throw cacheResetError + } - if let cacheResetError { - throw cacheResetError + try await wallet.waitForPaymentRoutingDataRefresh(startedAt: refreshStartedAt) } - try await wallet.waitForPaymentRoutingDataRefresh(startedAt: refreshStartedAt) - onRetryReady(true) + await onRetryReady(context.resetRoutingCachesOnRetry) } catch { Logger.error("Failed to reset routing caches before payment retry: \(error)", context: "SendFailure") app.toast(error) 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..748dc7932 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 @@ -85,14 +102,23 @@ struct SendSheet: View { @Environment(PaykitPaymentRequestManager.self) private var paykitPaymentRequestManager let config: SendSheetItem + let isEmbedded: Bool + @State private var rootRoute: SendRoute + @State private var rootRouteGeneration = 0 @State private var navigationPath: [SendRoute] = [] @State private var hasValidatedAfterSync = false - @State private var incomingPaymentRequest: PaykitPaymentRequest? + @State private var pendingEmbeddedRetryRoute: SendRoute? @State private var routingCacheResetAttempted = false @State private var syncTimedOut = false @State private var pinCheckContinuations: [CheckedContinuation] = [] + init(config: SendSheetItem, isEmbedded: Bool = false) { + self.config = config + self.isEmbedded = isEmbedded + _rootRoute = State(initialValue: config.initialRoute) + } + /// How long the sync overlay may wait for channels to become usable before falling back private static let syncTimeoutSeconds: TimeInterval = 20 @@ -139,19 +165,21 @@ struct SendSheet: View { ) } + private var startsOnFailure: Bool { + if case .failure = rootRoute { + return true + } + return false + } + var body: some View { - Sheet(id: .send, data: config) { - if shouldShowSyncOverlay { - SendSyncScreen() - .transition(.opacity) + Group { + if isEmbedded { + content } else { - NavigationStack(path: $navigationPath) { - viewForRoute(config.initialRoute) - .navigationDestination(for: SendRoute.self) { route in - viewForRoute(route) - } + Sheet(id: .send, data: config) { + content } - .transition(.opacity) } } .animation(.easeInOut(duration: 0.3), value: shouldShowSyncOverlay) @@ -160,27 +188,39 @@ 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") + sheets.hideSheetIfActive( + isEmbedded ? .subscription : .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() } // A plain Send open (TabBar) must not inherit invoice state from an earlier scan, // e.g. one abandoned behind the sync overlay. Invoice-carrying opens use other routes. - if config.initialRoute == .options { + if rootRoute == .options { app.resetSendState() } @@ -192,12 +232,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: @@ -216,8 +250,7 @@ struct SendSheet: View { .onChange(of: wallet.hasUsableChannels) { _, hasUsable in // Only validate if channels just became usable and we have a scanned invoice // (Validation already happened in AppViewModel if channels were already usable) - let hasScannedInvoice = app.scannedLightningInvoice != nil || app.scannedOnchainInvoice != nil || app.lnurlPayData != nil - guard hasScannedInvoice else { return } + guard app.hasSendPaymentTarget else { return } let isLightningPayment = app.scannedLightningInvoice != nil || app.lnurlPayData != nil @@ -244,6 +277,32 @@ struct SendSheet: View { handleSyncTimeout() } + .onDisappear(perform: cleanup) + } + + @ViewBuilder + private var content: some View { + if shouldShowSyncOverlay { + SendSyncScreen() + .transition(.opacity) + } else { + NavigationStack(path: $navigationPath) { + viewForRoute(rootRoute) + .navigationDestination(for: SendRoute.self) { route in + viewForRoute(route) + } + } + .id(rootRouteGeneration) + .transition(.opacity) + } + } + + private func cleanup() { + if let request = app.contactPaymentContext?.incomingPaymentRequest { + Task { await paykitPaymentRequestManager.finishPayment(request) } + } + app.resetSendState() + wallet.resetSendState(speed: settings.defaultTransactionSpeed) } /// Called when the sync overlay has been visible for `syncTimeoutSeconds` without channels becoming usable. @@ -267,8 +326,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 +334,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,26 +356,71 @@ 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) + let failureRoute = SendRoute.failure(SendFailureContext( + error: error, + retryRoute: app.lnurlPayData == nil ? .confirm : .lnurlPayConfirm, + paymentRequest: app.scannedLightningInvoice?.bolt11, + contactPaymentContext: context + )) + if isEmbedded { + replaceRootRoute(with: failureRoute) + } else { + navigationPath.append(failureRoute) + } + return + } + + app.toast( + type: .error, + title: title, + description: description, + accessibilityIdentifier: accessibilityIdentifier + ) + sheets.hideSheet() + } + + private enum PaymentValidationResult { + case ready + case waiting + case failed + } + /// 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 /// Pass `ignoreChannelWait: true` to validate even while channels are unusable (sync timeout). private func validatePaymentAfterSync(ignoreChannelWait: Bool = false) { + let result = performPaymentValidationAfterSync(ignoreChannelWait: ignoreChannelWait) + guard let route = pendingEmbeddedRetryRoute else { return } + + switch result { + case .ready: + pendingEmbeddedRetryRoute = nil + replaceRootRoute(with: route) + case .failed: + pendingEmbeddedRetryRoute = nil + case .waiting: + break + } + } + + private func performPaymentValidationAfterSync(ignoreChannelWait: Bool) -> PaymentValidationResult { let requestedAmount = app.contactPaymentContext?.incomingPaymentRequest?.amountSats 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,31 +428,27 @@ struct SendSheet: View { ), accessibilityIdentifier: "LnurlPayAmountTooLowToast" ) - sheets.hideSheet() hasValidatedAfterSync = true - return + return .failed } 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 + return .failed } guard LightningService.shared.canSend(amountSats: requestedAmount) else { let spendingBalance = LightningService.shared.balances?.totalLightningBalanceSats ?? 0 showInsufficientSpendingToast(invoiceAmount: requestedAmount, spendingBalance: spendingBalance) - sheets.hideSheet() hasValidatedAfterSync = true - return + return .failed } hasValidatedAfterSync = true - return + return .ready } // Validate lightning payment if present @@ -364,7 +460,7 @@ struct SendSheet: View { let hasAnyChannels = (wallet.channels?.isEmpty == false) || wallet.channelCount > 0 if hasAnyChannels, !wallet.hasUsableChannels, !ignoreChannelWait { // We have channels but none usable yet → wait - return + return .waiting } // Check if we can afford the lightning payment @@ -386,7 +482,7 @@ struct SendSheet: View { onchainBalance: onchainBalance ) else { hasValidatedAfterSync = true - return + return .failed } // Onchain balance is sufficient → navigate to amount screen @@ -395,19 +491,18 @@ struct SendSheet: View { navigationPath = [.amount] } hasValidatedAfterSync = true - return + return .ready } else { // 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 + return .failed } } else { // Lightning payment is valid, we're done hasValidatedAfterSync = true - return + return .ready } } @@ -419,11 +514,12 @@ struct SendSheet: View { onchainBalance: onchainBalance ) else { hasValidatedAfterSync = true - return + return .failed } } hasValidatedAfterSync = true + return .ready } private func requestPinCheck() async -> Bool { @@ -479,16 +575,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 +596,18 @@ struct SendSheet: View { if didResetRoutingCaches { routingCacheResetAttempted = true } - resetNavigationForRetry(context.retryRoute) - } + if let requestId = context.incomingPaymentRequestId { + await 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 +649,139 @@ struct SendSheet: View { let route = retryRoute.sendRoute navigationPath = route == config.initialRoute ? [] : [route] } + + private func replaceRootRoute(with route: SendRoute) { + rootRoute = route + navigationPath = [] + rootRouteGeneration &+= 1 + } + + @MainActor + private func retryIncomingPaymentRequest( + _ requestId: PaykitPaymentRequest.ID, + isInitialSubscriptionPayment: Bool + ) async { + guard let request = paykitPaymentRequestManager.paymentRequestForRetry(requestId) else { + app.toast(PaykitPaymentRequestError.requestUnavailable) + return + } + + if isEmbedded, isInitialSubscriptionPayment { + await retryEmbeddedInitialSubscriptionPayment(request) + 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") + } + + @MainActor + private func retryEmbeddedInitialSubscriptionPayment(_ request: PaykitPaymentRequest) async { + guard sheets.activeSheetConfiguration?.id == .subscription else { return } + + app.resetSendState() + wallet.resetSendState(speed: settings.defaultTransactionSpeed) + wallet.sendAmountSats = request.amountSats + + let resolution: PublicPaykitPaymentLaunchResult + do { + resolution = try await PrivatePaykitService.shared.beginPaymentRequestWaitingForUpdatedList(request) + } catch { + showEmbeddedInitialPaymentFailure(request, error: error) + return + } + + guard case let .opened(paymentTarget, privatePaymentContext) = resolution else { + showEmbeddedInitialPaymentFailure(request, error: PaykitPaymentRequestError.requestUnavailable) + return + } + guard sheets.activeSheetConfiguration?.id == .subscription else { return } + + let context = ContactPaymentContext( + publicKey: request.counterparty, + privatePaymentContext: privatePaymentContext, + incomingPaymentRequest: request, + isInitialSubscriptionPayment: true + ) + guard app.claimContactPaymentContext(context) else { + app.toast(PaykitPaymentRequestError.operationInProgress) + return + } + + do { + try await app.handleScannedData(paymentTarget, claimedContactPaymentContext: context) + } catch { + guard sheets.activeSheetConfiguration?.id == .subscription, + app.ownsContactPaymentContext(context) + else { + if app.ownsContactPaymentContext(context) { + app.resetSendState() + } + return + } + showEmbeddedInitialPaymentFailure(request, error: error, context: context) + return + } + + guard sheets.activeSheetConfiguration?.id == .subscription, + app.ownsContactPaymentContext(context) + else { + if app.ownsContactPaymentContext(context) { + app.resetSendState() + } + return + } + guard app.hasSendPaymentTarget else { + showEmbeddedInitialPaymentFailure( + request, + error: PaykitPaymentRequestError.requestUnavailable, + context: context + ) + return + } + + pendingEmbeddedRetryRoute = app.lnurlPayData == nil ? .confirm : .lnurlPayConfirm + hasValidatedAfterSync = false + guard !shouldShowSyncOverlay else { return } + validatePaymentAfterSync() + } + + @MainActor + private func showEmbeddedInitialPaymentFailure( + _ request: PaykitPaymentRequest, + error: Error, + context existingContext: ContactPaymentContext? = nil + ) { + guard sheets.activeSheetConfiguration?.id == .subscription else { + if let existingContext, app.ownsContactPaymentContext(existingContext) { + app.resetSendState() + } + return + } + + let context = existingContext ?? ContactPaymentContext( + publicKey: request.counterparty, + incomingPaymentRequest: request, + isInitialSubscriptionPayment: true + ) + if !app.ownsContactPaymentContext(context), !app.claimContactPaymentContext(context) { + app.toast(PaykitPaymentRequestError.operationInProgress) + return + } + + let failure = SendFailureContext( + error: error, + retryRoute: .confirm, + routingCacheResetAttempted: routingCacheResetAttempted, + contactPaymentContext: context + ) + replaceRootRoute(with: .failure(failure)) + } } private struct SendComingSoonView: View { diff --git a/Bitkit/Views/Wallets/Send/SendSuccess.swift b/Bitkit/Views/Wallets/Send/SendSuccess.swift index c2170f0f6..62522ff3c 100644 --- a/Bitkit/Views/Wallets/Send/SendSuccess.swift +++ b/Bitkit/Views/Wallets/Send/SendSuccess.swift @@ -10,9 +10,14 @@ 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? + private var paymentProofKind: PaykitPaymentProofKind { + app.selectedWalletToPayFrom == .onchain ? .onchain : .lightning + } + private var successDisplaySats: Int? { if let sendAmountSats = wallet.sendAmountSats { return Int(sendAmountSats) @@ -31,8 +36,7 @@ struct SendSuccess: View { /// Load the confetti animation private var confettiAnimation: LottieAnimation? { - let isOnchain = app.selectedWalletToPayFrom == .onchain - let animationName = isOnchain ? "confetti-orange" : "confetti-purple" + let animationName = paymentProofKind == .onchain ? "confetti-orange" : "confetti-purple" guard let filepathURL = Bundle.main.url(forResource: animationName, withExtension: "json") else { print("Could not find \(animationName).json in bundle") @@ -43,6 +47,22 @@ struct SendSuccess: View { } var body: some View { + Group { + if isInitialSubscriptionPayment { + SubscriptionSuccessView(paymentProofKind: paymentProofKind) { + 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 +114,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..6224bfbd5 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -8,6 +8,47 @@ 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 testSubscriptionHistoryKeepsPaymentProofKind() throws { + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + let billingPeriod = BillingPeriod( + startsAt: "2027-01-01T08:00:00.000Z", + endsAt: "2027-02-01T08:00:00.000Z" + ) + let acceptedAt = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-01T08:00:00Z")) + let through = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let cases: [(PublicPaykitService.MethodId, PaykitPaymentProofKind)] = [ + (.bitcoinLightningBolt11, .lightning), + (.regtestOnchainP2wpkh, .onchain), + ] + + for (method, proofKind) in cases { + let proof = try paymentProofRecord( + endpoint: method.rawValue, + kind: proofKind, + data: String(repeating: "01", count: 32), + billingPeriod: billingPeriod + ) + let record = try paymentRequestRecord( + endpoints: [method.rawValue], + paymentProofs: [proof], + state: .activeRecurring, + recurrence: recurrence + ) + let subscription = try XCTUnwrap(PaykitSubscription(record: record)) + let request = try XCTUnwrap(subscription.requests(through: through, acceptedAt: acceptedAt).first) + + XCTAssertEqual(request.lifecycleState, .proofSubmitted) + XCTAssertEqual(request.paymentProofKind, proofKind) + } + } func testCompletedLightningPaymentRetriesAfterRestart() async throws { let record = try paymentRequestRecord() @@ -27,8 +68,10 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let failedSubmissionCount = await sdk.submissionCount() let persistedProof = await store.snapshot().first + let completedProofKinds = await service.completedRequestProofKindsAwaitingSubmission(identity: identity) XCTAssertEqual(failedSubmissionCount, 1) XCTAssertEqual(persistedProof?.proofData, preimage) + XCTAssertEqual(completedProofKinds, [request.id: .lightning]) await sdk.setSubmissionFailure(false) let restartedService = paymentProofService( @@ -120,6 +163,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 +266,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 +282,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 +398,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 +520,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 +534,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 +566,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 +584,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 +604,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 +622,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 +644,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 +653,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 +702,10 @@ private actor PaymentProofMemoryStore: PaykitPaymentProofStoring { func snapshot() -> [PendingPaykitPaymentProof] { proofs } + + func seed(_ proofs: [PendingPaykitPaymentProof]) { + self.proofs = proofs + } } private struct PaymentProofLightningLookup: PaykitLightningPaymentProofLookingUp { @@ -366,6 +716,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..4e252bc22 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,573 @@ 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), + completedPaymentProofKinds: [request.id: .lightning] + ) + + await manager.refresh() + + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertEqual(manager.historyRequests.first?.id, request.id) + XCTAssertEqual(manager.historyRequests.first?.lifecycleState, .proofSubmitted) + XCTAssertEqual(manager.historyRequests.first?.paymentProofKind, .lightning) + } + + func testCompletedOneTimePaymentAwaitingProofSubmissionKeepsPaymentProofKind() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let record = try paymentRequestRecord(state: .accepted) + let request = try XCTUnwrap(PaykitPaymentRequest(historyRecord: record, now: now)) + + for proofKind in [PaykitPaymentProofKind.lightning, .onchain] { + let manager = paymentRequestManager( + sdk: PaymentRequestSdkMock(records: [record]), + clock: PaymentRequestTestClock(now), + completedPaymentProofKinds: [request.id: proofKind] + ) + + await manager.refresh() + + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertEqual(manager.historyRequests.first?.id, request.id) + XCTAssertEqual(manager.historyRequests.first?.lifecycleState, .proofSubmitted) + XCTAssertEqual(manager.historyRequests.first?.paymentProofKind, proofKind) + } + } + + func testOneTimeHistoryKeepsPaymentProofKind() throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let cases: [(PublicPaykitService.MethodId, PaykitPaymentProofKind)] = [ + (.bitcoinLightningBolt11, .lightning), + (.regtestOnchainP2wpkh, .onchain), + ] + + for (method, proofKind) in cases { + let proof = try paymentProofRecord(endpoint: method.rawValue, kind: proofKind) + let record = try paymentRequestRecord( + state: .proofSubmitted, + endpoints: [method.rawValue], + paymentProofs: [proof] + ) + let request = try XCTUnwrap(PaykitPaymentRequest(historyRecord: record, now: now)) + + XCTAssertEqual(request.paymentProofKind, proofKind) + } + } + + 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 +916,12 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let now: @Sendable () -> Date = { Date() } let manager = PaykitPaymentRequestManager( service: PaykitPaymentRequestService(sdk: sdk, now: now, logWarning: { _ in }), + subscriptionStateStore: PaymentRequestSubscriptionStateMemoryStore(), + completedPaymentProofKinds: { _ in [:] }, now: now, logWarning: { _ in } ) + manager.activate(identity: "pubky\(String(repeating: "z", count: 52))") await manager.refresh() XCTAssertEqual(manager.pendingRequests.count, 1) @@ -463,7 +1187,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 +1196,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 +1384,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 +1399,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 +1411,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 +1837,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let manager = PaykitPaymentRequestManager( service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), presentationStore: store, + subscriptionStateStore: PaymentRequestSubscriptionStateMemoryStore(), isAvailable: { true }, logWarning: { _ in } ) @@ -1094,7 +1861,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { private func paymentRequestManager( sdk: PaymentRequestSdkMock, clock: PaymentRequestTestClock = PaymentRequestTestClock(Date()), - isPrivatePaymentPublishingEnabled: Bool = true + isPrivatePaymentPublishingEnabled: Bool = true, + completedPaymentProofKinds: [PaykitPaymentRequest.ID: PaykitPaymentProofKind] = [:], + inFlightPaymentRequestIds: Set = [], + protectedRequestIdsForSubscriptionCancellation: Set = [] ) -> PaykitPaymentRequestManager { let now: @Sendable () -> Date = { clock.now() } let manager = PaykitPaymentRequestManager( @@ -1105,6 +1875,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { logWarning: { _ in } ), presentationStore: PaymentRequestPresentationMemoryStore(), + subscriptionStateStore: PaymentRequestSubscriptionStateMemoryStore(), + completedPaymentProofKinds: { _ in completedPaymentProofKinds }, + inFlightPaymentRequestIds: { _ in inFlightPaymentRequestIds }, + protectedRequestIdsForSubscriptionCancellation: { _, _ in protectedRequestIdsForSubscriptionCancellation }, now: now, isAvailable: { true }, logWarning: { _ in } @@ -1125,8 +1899,11 @@ 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, + paymentProofs: [PaymentProofRecord] = [] ) throws -> PaymentRequestRecord { try PaymentRequestRecord( counterparty: counterparty, @@ -1146,21 +1923,38 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { acceptedPaymentEndpointIdentifiers: endpoints, metadata: PrivateJsonObject(text: metadata) ), - acceptedEventId: nil, + acceptedEventId: acceptedEventId, acceptedOutboundStatus: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, canceledOutboundStatus: nil, - paymentProofs: [], + paymentProofs: paymentProofs, lastStreamItemId: 1, lastOutboundMessageId: nil, lastOutboundStatus: nil, - lastEventAt: "2027-01-15T08:00:00Z", + lastEventAt: lastEventAt, invalidReason: nil ) } + private func paymentProofRecord( + endpoint: String, + kind: PaykitPaymentProofKind + ) throws -> PaymentProofRecord { + try PaymentProofRecord( + eventId: "750e8400-e29b-41d4-a716-446655440000", + outboundMessageId: nil, + outboundStatus: nil, + streamItemId: 2, + paymentReference: PaymentReference(text: "invoice-123"), + billingPeriod: nil, + paymentEndpointIdentifier: endpoint, + proof: PrivateJsonObject(text: "{\"data\":\"proof\",\"type\":\"\(kind.rawValue)\"}"), + recordedAt: "2027-01-15T08:01:00Z" + ) + } + private func timestamp(_ date: Date) -> String { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] @@ -1196,6 +1990,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 +2141,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 +2192,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 +2401,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/685.added.md b/changelog.d/next/685.added.md new file mode 100644 index 000000000..71b66fce6 --- /dev/null +++ b/changelog.d/next/685.added.md @@ -0,0 +1 @@ +Bitkit can now review, manage, and pay recurring payment requests from Paykit contacts.