From e3693082d1b4a8999bd35af67ae6c4e6de6ac897 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 18 Oct 2026 18:55:52 -0600 Subject: [PATCH 01/10] fix: reuse initial USDT subscription authorization --- Bitkit/ViewModels/AppViewModel.swift | 16 +++++++ .../Subscriptions/SubscriptionsView.swift | 16 +++++-- Bitkit/Views/Wallets/Send/SendSheet.swift | 13 ++++- Bitkit/Views/Wallets/UsdtSendView.swift | 47 ++++++++++++------- .../PaykitPaymentRequestServiceTests.swift | 36 ++++++++++++++ changelog.d/next/981.fixed.md | 1 + .../subscriptions/review-and-subscribe.xml | 12 +++-- 7 files changed, 114 insertions(+), 27 deletions(-) create mode 100644 changelog.d/next/981.fixed.md diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 250f88091..9f7d777c2 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -37,6 +37,22 @@ struct ContactPaymentContext: Equatable { incomingPaymentRequest.map { $0.amount.asset != .btc || $0.pricing.conversion != nil } == true } + func initialUsdtSubscriptionPayment(at date: Date = Date()) -> PaykitRequestPricing.Payment? { + guard isInitialSubscriptionPayment, let request = incomingPaymentRequest, request.billingPeriod != nil, + !endpoints.isEmpty, endpoints.allSatisfy({ $0.methodId == .usdtArbitrum }), + !request.isExpired(at: date) + else { return nil } + switch (request.amount.asset, request.pricing.conversion) { + case (.usdt, nil): break + case let (.usd, .fixed(rates)): + let rate = rates.first { $0.asset == "usdt-arbitrum" } ?? rates.first { $0.asset == "usdt" } + guard let rate, Decimal(string: rate.value, locale: Locale(identifier: "en_US_POSIX")) == 1 else { return nil } + default: return nil + } + guard let payment = try? request.payment(using: .usdtArbitrum, at: date), payment.isValid(at: date) else { return nil } + return payment + } + func prefersUsdt( onchainBalanceSats: UInt64, canSendLightning: Bool = false, diff --git a/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift index 03207b397..476658eef 100644 --- a/Bitkit/Views/Subscriptions/SubscriptionsView.swift +++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift @@ -754,8 +754,16 @@ struct SubscriptionSheet: View { return } - let sendRoute: SendRoute = context.requiresAssetSelection ? .amount : app.lnurlPayData == nil ? .confirm : .lnurlPayConfirm - route = .payment(sendRoute) + if app.paykitUsesUsdt, let terms = context.initialUsdtSubscriptionPayment(), + let recipient = context.endpoints.first?.value + { + app.paykitPaymentTerms = terms + app.paykitReviewedAmount = terms.amount + route = .payment(.usdt(recipient: recipient, amount: terms.amount.value)) + } else { + let sendRoute: SendRoute = context.requiresAssetSelection ? .amount : app.lnurlPayData == nil ? .confirm : .lnurlPayConfirm + route = .payment(sendRoute) + } } @MainActor @@ -891,7 +899,9 @@ struct SubscriptionSuccessView: View { var body: some View { ZStack { - if let animation = confettiAnimation { + if paymentProofKind == .usdt { + PaymentCelebration(isOnchain: true, isReceived: false, confettiColor: .usdtAccent, isUsdt: true) + } else if let animation = confettiAnimation { LottieView(animation: animation) .playing(loopMode: .loop) .scaleEffect(1.9) diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index cfcbf0f74..471436ab6 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -859,7 +859,10 @@ struct SendSheet: View { if let request = context.incomingPaymentRequest { try await paykitPaymentRequestManager.ensurePaymentAllowed(request) } guard app.ownsContactPaymentContext(context) else { throw PaykitPaymentRequestError.requestUnavailable } } - }, onBack: { navigationPath.removeLast() }, onClose: { sheets.hideSheet() }) + }, onBack: { + if navigationPath.isEmpty { sheets.hideSheet() } + else { navigationPath.removeLast() } + }, onClose: { sheets.hideSheet() }) case .utxoSelection: SendUtxoSelectionView(navigationPath: $navigationPath) case .confirm: @@ -1321,7 +1324,13 @@ struct SendSheet: View { return } } - pendingEmbeddedRetryRoute = context.requiresAssetSelection ? .amount : app.lnurlPayData == nil ? .confirm : .lnurlPayConfirm + if app.paykitUsesUsdt, let terms = context.initialUsdtSubscriptionPayment(), let recipient = context.endpoints.first?.value { + app.paykitPaymentTerms = terms + app.paykitReviewedAmount = terms.amount + pendingEmbeddedRetryRoute = .usdt(recipient: recipient, amount: terms.amount.value) + } else { + pendingEmbeddedRetryRoute = context.requiresAssetSelection ? .amount : app.lnurlPayData == nil ? .confirm : .lnurlPayConfirm + } hasValidatedAfterSync = false guard !shouldShowSyncOverlay else { return } validatePaymentAfterSync() diff --git a/Bitkit/Views/Wallets/UsdtSendView.swift b/Bitkit/Views/Wallets/UsdtSendView.swift index 7fbd6b59a..457f1061a 100644 --- a/Bitkit/Views/Wallets/UsdtSendView.swift +++ b/Bitkit/Views/Wallets/UsdtSendView.swift @@ -33,6 +33,7 @@ struct UsdtSendView: View { @State private var error: String? @State private var pin = false @State private var submitted = false + @State private var hasStartedInitialPayment = false @State private var paymentComplete = false @State private var showDetails = false @State private var showFullRecipient = false @@ -113,9 +114,34 @@ struct UsdtSendView: View { UsdtIntroContent(isEnablePrompt: true, onDecline: { if let onClose { onClose() } else { dismiss() } }, onEnable: { settings.usdtPreference = .enabled }) + } else if paymentContext?.isInitialSubscriptionPayment == true, submitted, !busy, submittedStatus == .confirmed { + SubscriptionSuccessView(paymentProofKind: .usdt) { + if let onClose { onClose() } else { dismiss() } + } } else if embedded { content } else { NavigationStack { content } } } + .task(id: "\(scenePhase == .active)-\(submitted)") { + guard scenePhase == .active, submitted else { return } + if busy, let quote { + if quote.destination == .arbitrum { await usdt.waitForTransfer(quote.id) } + if submittedStatus == .confirmed { + paymentComplete = true + do { try await Task.sleep(for: .seconds(1)) } catch { return } + } + busy = false + } + while !Task.isCancelled, [.pending, .bridging, .bridgeNeedsAttention].contains(submittedStatus) { + await usdt.refresh() + do { try await Task.sleep(for: .seconds(10)) } catch { return } + } + await PaykitUsdtPaymentService.shared.reconcile(wallet: usdt) + } + .overlay { + if paymentContext?.isInitialSubscriptionPayment == true, busy { + InitialSubscriptionPaymentProgress() + } + } .preference(key: SheetBackgroundPreferenceKey.self, value: .gray7) .onAppear { usdt.isSendPresented = true } .onDisappear { usdt.isSendPresented = false; preparedQuote = nil } @@ -128,10 +154,13 @@ struct UsdtSendView: View { editingAmount, !showingNetworks, !initialRecipient.isEmpty, recipient == initialRecipient, amount == initialAmount, destination == .arbitrum, (try? parsePaykitAmountInput(asset: .usdt, value: amount)) != nil else { return } + let automaticPayment = !hasStartedInitialPayment && paymentContext?.initialUsdtSubscriptionPayment()?.amount.value == amount + hasStartedInitialPayment = true busy = true - defer { busy = false } do { quote = try await prepareQuote() } catch { self.error = UsdtWalletManager.message(for: error) } + busy = false + if automaticPayment, !Task.isCancelled, quote != nil { await confirmPayment() } } } @@ -163,22 +192,6 @@ struct UsdtSendView: View { Task { await send() } }) } - .task(id: "\(scenePhase == .active)-\(submitted)") { - guard scenePhase == .active, submitted else { return } - if busy, let quote { - if quote.destination == .arbitrum { await usdt.waitForTransfer(quote.id) } - if submittedStatus == .confirmed { - paymentComplete = true - do { try await Task.sleep(for: .seconds(1)) } catch { return } - } - busy = false - } - while !Task.isCancelled, [.pending, .bridging, .bridgeNeedsAttention].contains(submittedStatus) { - await usdt.refresh() - do { try await Task.sleep(for: .seconds(10)) } catch { return } - } - await PaykitUsdtPaymentService.shared.reconcile(wallet: usdt) - } .interactiveDismissDisabled(busy || pin) .alert(t("common__are_you_sure"), isPresented: Binding( get: { warning != nil }, diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 9003989bd..d9ebe787c 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -54,6 +54,42 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(usdtOnly.prefersUsdt(onchainBalanceSats: 100_000, usdtBalance: 0)) } + func testInitialUsdtSubscriptionAuthorizationRequiresUnchangedTermsAndSingleMethod() throws { + let now = try XCTUnwrap(PaykitPaymentRequest.parseDate("2027-01-01T08:00:00Z")) + let period = PaykitBillingPeriod(startsAt: now, endsAt: now.addingTimeInterval(3600)) + let usdt = try PaykitUsdt.endpoint(address: "0x1111111111111111111111111111111111111111") + let bitcoin = PublicPaykitService.Endpoint(methodId: .regtestOnchainP2wpkh, value: "bcrt1qexample", + min: nil, max: nil, rawPayload: "bcrt1qexample") + let cases: [(String, PaymentConversion?, Bool)] = [ + ("usdt", nil, true), + ("usd", .fixed(rates: [.init(asset: "usdt", value: "1")]), true), + ("usd", .fixed(rates: [.init(asset: "usdt", value: "1.0")]), true), + ("usd", .fixed(rates: [.init(asset: "usdt", value: "1"), .init(asset: "usdt-arbitrum", value: "2")]), false), + ("usd", .fixed(rates: [.init(asset: "usdt", value: "2")]), false), + ("usdt", .fixed(rates: [.init(asset: "usdt", value: "2")]), false), + ("usd", .perPeriod, false), + ("btc", .fixed(rates: [.init(asset: "usdt", value: "100000")]), false), + ] + for (asset, conversion, eligible) in cases { + let record = try paymentRequestRecord(state: .activeRecurring, amount: "1", asset: asset, conversion: conversion, + paymentDeadline: .periodStart(seconds: 60), + recurrence: .init(every: 1, unit: "month", startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", endsAt: nil), + endpoints: [usdt.methodId.rawValue]) + let subscription = try XCTUnwrap(PaykitSubscription(record: record)) + let request = PaykitPaymentRequest(subscription: subscription, billingPeriod: period, lifecycleState: .accepted) + let context = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request, + isInitialSubscriptionPayment: true, endpoints: [usdt]) + XCTAssertEqual(context.initialUsdtSubscriptionPayment(at: now) != nil, eligible) + XCTAssertNil(context.initialUsdtSubscriptionPayment(at: now.addingTimeInterval(61))) + let recurring = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request, endpoints: [usdt]) + XCTAssertNil(recurring.initialUsdtSubscriptionPayment(at: now)) + let multipleMethods = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request, + isInitialSubscriptionPayment: true, endpoints: [usdt, bitcoin]) + XCTAssertNil(multipleMethods.initialUsdtSubscriptionPayment(at: now)) + } + } + func testMonthlyCostCombinesAssetsUsingTheWalletRate() { let now = Date() let amounts = [ diff --git a/changelog.d/next/981.fixed.md b/changelog.d/next/981.fixed.md new file mode 100644 index 000000000..e6879f3e3 --- /dev/null +++ b/changelog.d/next/981.fixed.md @@ -0,0 +1 @@ +Eligible initial USDT subscription payments now continue after subscribing without a second payment swipe, while retaining payment authentication and safety checks. diff --git a/journeys/subscriptions/review-and-subscribe.xml b/journeys/subscriptions/review-and-subscribe.xml index 0dd55c7f6..5a2c31ea6 100644 --- a/journeys/subscriptions/review-and-subscribe.xml +++ b/journeys/subscriptions/review-and-subscribe.xml @@ -3,10 +3,11 @@ Payer side of create-and-propose.xml. The proposed subscription arrives, surfaces under PROPOSALS, and opens the Review and Subscribe sheet where the payer swipes to accept. Accepting does not return to the list on its own: when the first period is due on acceptance the first payment starts - by itself, from Lightning or on-chain savings, with no second swipe; otherwise the Subscribed + by itself, from Lightning, on-chain savings or an eligible USDT-only payment, with no second swipe; otherwise the Subscribed confirmation opens directly. Both paths end on Close. A hardware wallet still shows the send confirmation for the first payment. - Run this on the payer instance immediately after the creator sends the proposal. + Run this on the payer instance immediately after the creator sends the proposal. For the USDT + steps, configure both apps for the local USDT testing stack and use local test funds only. The handoff's "Automatically pay this subscription" toggle is unbuilt on both platforms. @@ -31,9 +32,10 @@ Only when a payment was made: verify the PAYMENTS section lists it titled "Journey Sub" with the payment date as its subtitle. With no payment yet, the section is absent. Have the creator send a second proposal for 2 USD monthly, accepting only USDT, with its first period due on acceptance; open its Review and Subscribe sheet on the payer with USDT enabled and enough USDT for 2 USDT plus the payment fee Verify the requested amount is $2, USDT is the only payment method and the swipe knob is USDT teal; swipe to subscribe - Verify the Amount screen keeps $2 locked and USDT as its only payment source; no Bitcoin amount, Savings source or editable amount is offered - Tap Continue and verify the USDT review shows 2 USDT, the creator's address and the fee; do not swipe to pay - Close the send sheet, reopen this subscription's unpaid first period from Payments, and verify it still offers the locked 2 USDT payment; cancel without sending + Verify the Review and Subscribe layout shows payment preparation without another amount-entry step or payment swipe. Complete any enabled PIN or biometric authentication and required safety warnings. Wait for Subscribed, then tap Close. + Verify the first period has only one payment in activity and is no longer offered for payment. Reopen the subscription and verify its next billing period remains scheduled. + Have the creator send another USDT-only proposal. Enable PIN for payments before accepting it. Swipe to subscribe, cancel authentication, and verify no payment was sent. Use Back to close the payment sheet. Reopen the unpaid period and explicitly authorize it; verify one payment, not two. + Repeat with a USDT-only proposal whose amount exceeds the payer's balance including fees. Swipe to subscribe and verify an insufficient-funds or fee-collection error prevents payment. Close and reopen the unpaid period; verify it remains payable and no payment was recorded. Return to the Overview tab with both the Bitcoin and USD subscriptions active. Verify Monthly Cost shows one total in the selected display currency, using Bitkit's exchange rate, aligned with Active and Created rather than separate stacked amounts. From ba31357c0f9d66a8255691cf328bee14da8daeab Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 18 Oct 2026 19:31:36 -0600 Subject: [PATCH 02/10] Guard initial subscription method selection and cancellation --- Bitkit/ViewModels/AppViewModel.swift | 1 + Bitkit/Views/Wallets/UsdtSendView.swift | 16 +- .../PaykitPaymentRequestServiceTests.swift | 148 ++++++++++++++++++ 3 files changed, 164 insertions(+), 1 deletion(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 9f7d777c2..e2386e3b5 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -39,6 +39,7 @@ struct ContactPaymentContext: Equatable { func initialUsdtSubscriptionPayment(at date: Date = Date()) -> PaykitRequestPricing.Payment? { guard isInitialSubscriptionPayment, let request = incomingPaymentRequest, request.billingPeriod != nil, + Set(request.acceptedPaymentEndpointIdentifiers) == [PublicPaykitService.MethodId.usdtArbitrum.rawValue], !endpoints.isEmpty, endpoints.allSatisfy({ $0.methodId == .usdtArbitrum }), !request.isExpired(at: date) else { return nil } diff --git a/Bitkit/Views/Wallets/UsdtSendView.swift b/Bitkit/Views/Wallets/UsdtSendView.swift index 457f1061a..3e310d0b4 100644 --- a/Bitkit/Views/Wallets/UsdtSendView.swift +++ b/Bitkit/Views/Wallets/UsdtSendView.swift @@ -8,6 +8,8 @@ struct UsdtSendView: View { var initialAmount = "" var embedded = false var paymentContext: ContactPaymentContext? + var quotePayment: ((String, String, UsdtDestination) async throws -> UsdtQuote)? + var authenticatePayment: (() async -> Bool)? var sendPayment: ((UsdtQuote) async throws -> Void)? var onBack: (() -> Void)? var onClose: (() -> Void)? @@ -48,6 +50,8 @@ struct UsdtSendView: View { initialAmount: String = "", embedded: Bool = false, paymentContext: ContactPaymentContext? = nil, + quotePayment: ((String, String, UsdtDestination) async throws -> UsdtQuote)? = nil, + authenticatePayment: (() async -> Bool)? = nil, sendPayment: ((UsdtQuote) async throws -> Void)? = nil, onBack: (() -> Void)? = nil, onClose: (() -> Void)? = nil @@ -57,6 +61,8 @@ struct UsdtSendView: View { self.initialAmount = initialAmount self.embedded = embedded self.paymentContext = paymentContext + self.quotePayment = quotePayment + self.authenticatePayment = authenticatePayment self.sendPayment = sendPayment self.onBack = onBack self.onClose = onClose @@ -588,7 +594,8 @@ struct UsdtSendView: View { } preparedQuote = nil let sender = usdt.address - let result = try await usdt.quote(recipient: recipient, amount: amount, destination: destination) + let result: UsdtQuote = if let quotePayment { try await quotePayment(recipient, amount, destination) } + else { try await usdt.quote(recipient: recipient, amount: amount, destination: destination) } preparedQuote = UsdtPreparedQuote(quote: result, amount: amount, sender: sender) return result } @@ -634,6 +641,13 @@ struct UsdtSendView: View { private func authenticateAndSend() async { guard !busy else { return } + if let authenticatePayment { + busy = true + let authorized = await authenticatePayment() + busy = false + if authorized, !Task.isCancelled { await send() } + return + } if settings.requirePinForPayments && settings.pinEnabled { guard settings.useBiometrics && BiometricAuth.isAvailable else { pin = true diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index d9ebe787c..31f4522fc 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -90,6 +90,154 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } } + func testInitialSubscriptionWithMultipleAcceptedMethodsStillRequiresSelection() throws { + let now = Date() + let usdt = try PaykitUsdt.endpoint(address: "0x1111111111111111111111111111111111111111") + let record = try paymentRequestRecord( + state: .activeRecurring, amount: "1", asset: "usd", + conversion: .fixed(rates: [.init(asset: "usdt", value: "1"), .init(asset: "btc", value: "0.00001")]), + recurrence: .init(every: 1, unit: "month", startsAt: "2026-01-01T08:00:00Z", + anchor: "2026-01-01T08:00:00Z", endsAt: nil), + endpoints: [usdt.methodId.rawValue, PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue] + ) + let subscription = try XCTUnwrap(PaykitSubscription(record: record)) + let request = PaykitPaymentRequest(subscription: subscription, + billingPeriod: .init(startsAt: now, endsAt: now.addingTimeInterval(3600)), + lifecycleState: .accepted) + let context = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request, + isInitialSubscriptionPayment: true, endpoints: [usdt]) + XCTAssertNil(context.initialUsdtSubscriptionPayment(at: now)) + } + + func testInitialUsdtPreparationAuthorizesOnceAndDoesNotRepeatAfterAuthenticationCancellation() async throws { + for authorized in [true, false] { + var events: [String] = [] + let quoted = expectation(description: "Initial quote prepared") + let authenticated = expectation(description: "Initial payment authorization") + let sent = expectation(description: "Initial payment submitted") + sent.isInverted = !authorized + let window = try hostInitialUsdtPayment(quotePayment: { _, _, _ in + events.append("quote") + quoted.fulfill() + return self.initialUsdtQuote + }, authenticatePayment: { + events.append("authenticate") + authenticated.fulfill() + return authorized + }, sendPayment: { _ in + events.append("send") + sent.fulfill() + }) + defer { window.isHidden = true; window.rootViewController = nil } + await fulfillment(of: [quoted, authenticated], timeout: 3) + await fulfillment(of: [sent], timeout: authorized ? 3 : 0.2) + SettingsViewModel.shared.usdtPreference = .disabled + try await Task.sleep(for: .milliseconds(100)) + SettingsViewModel.shared.usdtPreference = .enabled + try await Task.sleep(for: .milliseconds(100)) + XCTAssertEqual(events, authorized ? ["quote", "authenticate", "send"] : ["quote", "authenticate"]) + } + } + + func testInitialUsdtQuoteFailureRequiresExplicitPaymentConfirmationAfterRecovery() async throws { + for error in [UsdtError.InsufficientBalance, .QuoteExpired] { + var attempts = 0 + let failed = expectation(description: "Initial quote failed") + let recovered = expectation(description: "Quote recovered") + let authorized = expectation(description: "Automatic authorization must stay consumed") + authorized.isInverted = true + let window = try hostInitialUsdtPayment(quotePayment: { _, _, _ in + attempts += 1 + if attempts == 1 { failed.fulfill(); throw error } + recovered.fulfill() + return self.initialUsdtQuote + }, authenticatePayment: { + authorized.fulfill() + return false + }, sendPayment: { _ in XCTFail("A failed preparation must never submit automatically") }) + defer { window.isHidden = true; window.rootViewController = nil } + await fulfillment(of: [failed], timeout: 3) + SettingsViewModel.shared.usdtPreference = .disabled + try await Task.sleep(for: .milliseconds(100)) + SettingsViewModel.shared.usdtPreference = .enabled + await fulfillment(of: [recovered], timeout: 3) + await fulfillment(of: [authorized], timeout: 0.2) + XCTAssertEqual(attempts, 2) + } + } + + func testCancelledInitialUsdtPreparationCannotAuthorizeAfterQuoteCompletes() async throws { + let started = expectation(description: "Quote started") + let finished = expectation(description: "Quote finished after close") + let gate = AsyncStream.makeStream() + defer { gate.continuation.finish() } + let window = try hostInitialUsdtPayment(quotePayment: { _, _, _ in + started.fulfill() + for await _ in gate.stream { + break + } + finished.fulfill() + return self.initialUsdtQuote + }, authenticatePayment: { + XCTFail("Closing the flow must not authorize a payment") + return false + }, sendPayment: { _ in XCTFail("Closing the flow must not submit a payment") }) + await fulfillment(of: [started], timeout: 3) + window.isHidden = true + window.rootViewController = nil + await fulfillment(of: [finished], timeout: 3) + try await Task.sleep(for: .milliseconds(100)) + } + + private var initialUsdtQuote: UsdtQuote { + UsdtQuote(bridgeProvider: nil, id: "subscription-quote", recipient: "0x1111111111111111111111111111111111111111", + destination: .arbitrum, amount: 1_000_000, receivedAmount: 1_000_000, maximumFee: 20, + expiresAt: UInt64(Date().timeIntervalSince1970) + 60) + } + + private func hostInitialUsdtPayment( + quotePayment: @escaping (String, String, UsdtDestination) async throws -> UsdtQuote, + authenticatePayment: @escaping () async -> Bool, + sendPayment: @escaping (UsdtQuote) async throws -> Void + ) throws -> UIWindow { + snapshotAppDefaultsDomain() + snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1") + let settings = SettingsViewModel.shared + let preference = settings.usdtPreference + addTeardownBlock { @MainActor in settings.usdtPreference = preference } + settings.usdtPreference = .enabled + let now = Date() + let endpoint = try PaykitUsdt.endpoint(address: initialUsdtQuote.recipient) + let record = try paymentRequestRecord( + state: .activeRecurring, amount: "1", asset: "usdt", + recurrence: .init(every: 1, unit: "month", startsAt: "2026-01-01T08:00:00Z", + anchor: "2026-01-01T08:00:00Z", endsAt: nil), + endpoints: [endpoint.methodId.rawValue] + ) + let subscription = try XCTUnwrap(PaykitSubscription(record: record)) + let request = PaykitPaymentRequest(subscription: subscription, + billingPeriod: .init(startsAt: now, endsAt: now.addingTimeInterval(3600)), + lifecycleState: .accepted) + let context = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request, + isInitialSubscriptionPayment: true, endpoints: [endpoint]) + XCTAssertNotNil(context.initialUsdtSubscriptionPayment()) + let manager = UsdtWalletManager(rpcUrl: nil, bundlerUrl: nil, loadCredentials: { throw UsdtError.InvalidCredentials }) + let view = UsdtSendView(onDetails: { _ in }, initialRecipient: endpoint.value, initialAmount: "1", embedded: true, + paymentContext: context, quotePayment: quotePayment, authenticatePayment: authenticatePayment, + sendPayment: sendPayment) + .environment(manager) + .environment(PaykitPaymentRequestManager()) + .environmentObject(AppViewModel()) + .environmentObject(ContactsManager()) + .environmentObject(CurrencyViewModel(currencyService: OfflineCurrencyService())) + .environmentObject(settings) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = UIHostingController(rootView: view) + window.makeKeyAndVisible() + window.rootViewController?.view.layoutIfNeeded() + return window + } + func testMonthlyCostCombinesAssetsUsingTheWalletRate() { let now = Date() let amounts = [ From 2e7865d048b59ab4762d650861027db718f3a859 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 19 Oct 2026 07:52:10 -0600 Subject: [PATCH 03/10] fix: keep manual subscription payment review --- Bitkit/Views/Wallets/UsdtSendView.swift | 9 +- .../PaykitPaymentRequestServiceTests.swift | 102 +++++++++++++++++- .../subscriptions/review-and-subscribe.xml | 4 +- 3 files changed, 106 insertions(+), 9 deletions(-) diff --git a/Bitkit/Views/Wallets/UsdtSendView.swift b/Bitkit/Views/Wallets/UsdtSendView.swift index 3e310d0b4..3074fd482 100644 --- a/Bitkit/Views/Wallets/UsdtSendView.swift +++ b/Bitkit/Views/Wallets/UsdtSendView.swift @@ -36,6 +36,7 @@ struct UsdtSendView: View { @State private var pin = false @State private var submitted = false @State private var hasStartedInitialPayment = false + @State private var automaticPayment = false @State private var paymentComplete = false @State private var showDetails = false @State private var showFullRecipient = false @@ -124,6 +125,8 @@ struct UsdtSendView: View { SubscriptionSuccessView(paymentProofKind: .usdt) { if let onClose { onClose() } else { dismiss() } } + .navigationBarHidden(true) + .allowSwipeBack(false) } else if embedded { content } else { NavigationStack { content } } } @@ -144,7 +147,7 @@ struct UsdtSendView: View { await PaykitUsdtPaymentService.shared.reconcile(wallet: usdt) } .overlay { - if paymentContext?.isInitialSubscriptionPayment == true, busy { + if automaticPayment, busy { InitialSubscriptionPaymentProgress() } } @@ -160,7 +163,7 @@ struct UsdtSendView: View { editingAmount, !showingNetworks, !initialRecipient.isEmpty, recipient == initialRecipient, amount == initialAmount, destination == .arbitrum, (try? parsePaykitAmountInput(asset: .usdt, value: amount)) != nil else { return } - let automaticPayment = !hasStartedInitialPayment && paymentContext?.initialUsdtSubscriptionPayment()?.amount.value == amount + automaticPayment = !hasStartedInitialPayment && paymentContext?.initialUsdtSubscriptionPayment()?.amount.value == amount hasStartedInitialPayment = true busy = true do { quote = try await prepareQuote() } @@ -335,6 +338,7 @@ struct UsdtSendView: View { isLoading: busy && !preparingMaximum, titleKerning: 0, titleLineHeight: 20) { guard !busy, quote == nil else { return } + automaticPayment = false busy = true error = nil defer { busy = false } @@ -433,6 +437,7 @@ struct UsdtSendView: View { title: t("wallet__send_swipe"), accentColor: .usdtAccent, isDisabled: busy, isLoading: busy, isSubmitted: submitted, isComplete: paymentComplete, swipeProgress: $swipeProgress ) { + automaticPayment = false await confirmPayment() if !submitted { throw CancellationError() } } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 31f4522fc..7a246ee02 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -7,6 +7,7 @@ import Paykit import SwiftUI import UIKit import UserNotifications +import Vision import XCTest @MainActor @@ -189,6 +190,77 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { try await Task.sleep(for: .milliseconds(100)) } + func testInitialUsdtProgressRequiresAutomaticAuthorization() async throws { + for automatic in [false, true] { + let started = expectation(description: "Quote started") + let gate = AsyncStream.makeStream() + defer { gate.continuation.finish() } + var authorizations = 0 + let window = try hostInitialUsdtPayment(automatic: automatic, quotePayment: { _, _, _ in + started.fulfill() + for await _ in gate.stream { + break + } + return self.initialUsdtQuote + }, authenticatePayment: { + authorizations += 1 + return false + }, sendPayment: { _ in XCTFail("Preparation must not send a payment") }) + defer { window.isHidden = true; window.rootViewController = nil } + await fulfillment(of: [started], timeout: 3) + try await Task.sleep(for: .milliseconds(200)) + let labels = try initialPaymentSnapshot(window, name: automatic ? "automatic-preparation" : "manual-preparation") + XCTAssertEqual(labels.contains(t("subscriptions__review_and_subscribe")), automatic, "\(labels)") + gate.continuation.yield(()) + try await Task.sleep(for: .milliseconds(300)) + XCTAssertEqual(authorizations, automatic ? 1 : 0) + let review = try initialPaymentSnapshot(window, name: automatic ? "automatic-cancelled" : "manual-review") + XCTAssertTrue(review.contains(t("wallet__send_swipe")), "\(review)") + } + } + + func testInitialUsdtSuccessCannotNavigateBackToPaidAmount() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let mnemonic = "test test test test test test test test test test test junk" + let manager = UsdtWalletManager(storageDirectory: directory, rpcUrl: nil, bundlerUrl: nil, loadCredentials: { (mnemonic, nil) }) + let address = try usdtAddress(mnemonic: mnemonic, passphrase: nil) + let snapshot = """ + {"identity":"42161:\(address)","transfers":[{"transfer":{ + "id":"subscription-quote","recipient":"\(initialUsdtQuote.recipient)","destination":"Arbitrum", + "amount":1000000,"received_amount":1000000,"fee":20,"is_incoming":false,"status":"Confirmed","timestamp":1 + }}]} + """ + let sent = expectation(description: "Confirmed initial payment") + let window = try hostInitialUsdtPayment(manager: manager, pushed: true, quotePayment: { _, _, _ in self.initialUsdtQuote }, + authenticatePayment: { true }, sendPayment: { _ in + try await manager.restoreBackup(snapshot) + sent.fulfill() + }) + defer { window.isHidden = true; window.rootViewController = nil } + await fulfillment(of: [sent], timeout: 3) + try await Task.sleep(for: .milliseconds(1500)) + let labels = try initialPaymentSnapshot(window, name: "subscription-success") + XCTAssertTrue(labels.contains(t("subscriptions__subscribed")), "\(labels)") + XCTAssertFalse(labels.contains("Back"), "\(labels)") + XCTAssertFalse(SwipeBackState.allowSwipeBack) + } + + private func initialPaymentSnapshot(_ window: UIWindow, name: String) throws -> [String] { + let view = try XCTUnwrap(window.rootViewController?.view) + let image = UIGraphicsImageRenderer(bounds: view.bounds).image { _ in + view.drawHierarchy(in: view.bounds, afterScreenUpdates: true) + } + let attachment = XCTAttachment(image: image) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + let request = VNRecognizeTextRequest() + request.recognitionLevel = .accurate + try VNImageRequestHandler(cgImage: XCTUnwrap(image.cgImage)).perform([request]) + return request.results?.compactMap { $0.topCandidates(1).first?.string } ?? [] + } + private var initialUsdtQuote: UsdtQuote { UsdtQuote(bridgeProvider: nil, id: "subscription-quote", recipient: "0x1111111111111111111111111111111111111111", destination: .arbitrum, amount: 1_000_000, receivedAmount: 1_000_000, maximumFee: 20, @@ -196,6 +268,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } private func hostInitialUsdtPayment( + automatic: Bool = true, + manager: UsdtWalletManager? = nil, + pushed: Bool = false, quotePayment: @escaping (String, String, UsdtDestination) async throws -> UsdtQuote, authenticatePayment: @escaping () async -> Bool, sendPayment: @escaping (UsdtQuote) async throws -> Void @@ -212,7 +287,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { state: .activeRecurring, amount: "1", asset: "usdt", recurrence: .init(every: 1, unit: "month", startsAt: "2026-01-01T08:00:00Z", anchor: "2026-01-01T08:00:00Z", endsAt: nil), - endpoints: [endpoint.methodId.rawValue] + endpoints: automatic ? [endpoint.methodId.rawValue] : [ + endpoint.methodId.rawValue, + PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + ] ) let subscription = try XCTUnwrap(PaykitSubscription(record: record)) let request = PaykitPaymentRequest(subscription: subscription, @@ -220,8 +298,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { lifecycleState: .accepted) let context = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request, isInitialSubscriptionPayment: true, endpoints: [endpoint]) - XCTAssertNotNil(context.initialUsdtSubscriptionPayment()) - let manager = UsdtWalletManager(rpcUrl: nil, bundlerUrl: nil, loadCredentials: { throw UsdtError.InvalidCredentials }) + XCTAssertEqual(context.initialUsdtSubscriptionPayment() != nil, automatic) + let manager = manager ?? UsdtWalletManager(rpcUrl: nil, bundlerUrl: nil, loadCredentials: { throw UsdtError.InvalidCredentials }) let view = UsdtSendView(onDetails: { _ in }, initialRecipient: endpoint.value, initialAmount: "1", embedded: true, paymentContext: context, quotePayment: quotePayment, authenticatePayment: authenticatePayment, sendPayment: sendPayment) @@ -231,8 +309,22 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { .environmentObject(ContactsManager()) .environmentObject(CurrencyViewModel(currencyService: OfflineCurrencyService())) .environmentObject(settings) - let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) - window.rootViewController = UIHostingController(rootView: view) + .environment(\.scenePhase, .active) + .preferredColorScheme(.dark) + let scene = try XCTUnwrap(UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive }) + let previousKeyWindow = scene.keyWindow + addTeardownBlock { + await MainActor.run { previousKeyWindow?.makeKey() } + } + let window = UIWindow(windowScene: scene) + window.frame = CGRect(x: 0, y: 0, width: 390, height: 844) + window.rootViewController = UIHostingController(rootView: NavigationStack(path: .constant(pushed ? [1] : [])) { + if pushed { + Color.black.navigationDestination(for: Int.self) { _ in view } + } else { view } + }) window.makeKeyAndVisible() window.rootViewController?.view.layoutIfNeeded() return window diff --git a/journeys/subscriptions/review-and-subscribe.xml b/journeys/subscriptions/review-and-subscribe.xml index 5a2c31ea6..1e8e1849a 100644 --- a/journeys/subscriptions/review-and-subscribe.xml +++ b/journeys/subscriptions/review-and-subscribe.xml @@ -32,9 +32,9 @@ Only when a payment was made: verify the PAYMENTS section lists it titled "Journey Sub" with the payment date as its subtitle. With no payment yet, the section is absent. Have the creator send a second proposal for 2 USD monthly, accepting only USDT, with its first period due on acceptance; open its Review and Subscribe sheet on the payer with USDT enabled and enough USDT for 2 USDT plus the payment fee Verify the requested amount is $2, USDT is the only payment method and the swipe knob is USDT teal; swipe to subscribe - Verify the Review and Subscribe layout shows payment preparation without another amount-entry step or payment swipe. Complete any enabled PIN or biometric authentication and required safety warnings. Wait for Subscribed, then tap Close. + Verify the Review and Subscribe layout shows payment preparation without another amount-entry step or payment swipe. Complete any enabled PIN or biometric authentication and required safety warnings. Wait for Subscribed and verify no Back button or swipe-back returns to the paid period; then tap Close. Verify the first period has only one payment in activity and is no longer offered for payment. Reopen the subscription and verify its next billing period remains scheduled. - Have the creator send another USDT-only proposal. Enable PIN for payments before accepting it. Swipe to subscribe, cancel authentication, and verify no payment was sent. Use Back to close the payment sheet. Reopen the unpaid period and explicitly authorize it; verify one payment, not two. + Have the creator send another USDT-only proposal. Enable PIN for payments before accepting it. Swipe to subscribe, cancel authentication, and verify no payment was sent. Use Back to close the payment sheet. Reopen the unpaid period and verify it waits at payment review without a completed Subscribe swipe or automatic-payment progress. Explicitly authorize it and verify one payment, not two. Repeat with a USDT-only proposal whose amount exceeds the payer's balance including fees. Swipe to subscribe and verify an insufficient-funds or fee-collection error prevents payment. Close and reopen the unpaid period; verify it remains payable and no payment was recorded. Return to the Overview tab with both the Bitcoin and USD subscriptions active. Verify Monthly Cost shows one total in the selected display currency, using Bitkit's exchange rate, aligned with Active and Created rather than separate stacked amounts. From 882ffbd5309443f06423798645e4d93b911f2c62 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 19 Oct 2026 08:42:22 -0600 Subject: [PATCH 04/10] fix: keep locked payment amounts on review --- Bitkit/Views/Wallets/UsdtSendView.swift | 3 ++- journeys/subscriptions/review-and-subscribe.xml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Bitkit/Views/Wallets/UsdtSendView.swift b/Bitkit/Views/Wallets/UsdtSendView.swift index 3074fd482..73282583d 100644 --- a/Bitkit/Views/Wallets/UsdtSendView.swift +++ b/Bitkit/Views/Wallets/UsdtSendView.swift @@ -405,7 +405,8 @@ struct UsdtSendView: View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { UsdtAmountHeader(amount: usdtFormatAmount(amount: quote.amount), displayAmount: usdtOverviewAmount(quote.amount)) - .contentShape(Rectangle()).onTapGesture { if !busy { goBack() } } + .contentShape(Rectangle()).onTapGesture(perform: goBack) + .allowsHitTesting(!busy && !embedded) Spacer().frame(height: 24) if showDetails { quoteDetails(quote) diff --git a/journeys/subscriptions/review-and-subscribe.xml b/journeys/subscriptions/review-and-subscribe.xml index 1e8e1849a..d3493359a 100644 --- a/journeys/subscriptions/review-and-subscribe.xml +++ b/journeys/subscriptions/review-and-subscribe.xml @@ -34,7 +34,7 @@ Verify the requested amount is $2, USDT is the only payment method and the swipe knob is USDT teal; swipe to subscribe Verify the Review and Subscribe layout shows payment preparation without another amount-entry step or payment swipe. Complete any enabled PIN or biometric authentication and required safety warnings. Wait for Subscribed and verify no Back button or swipe-back returns to the paid period; then tap Close. Verify the first period has only one payment in activity and is no longer offered for payment. Reopen the subscription and verify its next billing period remains scheduled. - Have the creator send another USDT-only proposal. Enable PIN for payments before accepting it. Swipe to subscribe, cancel authentication, and verify no payment was sent. Use Back to close the payment sheet. Reopen the unpaid period and verify it waits at payment review without a completed Subscribe swipe or automatic-payment progress. Explicitly authorize it and verify one payment, not two. + Have the creator send another USDT-only proposal. Enable PIN for payments before accepting it. Swipe to subscribe, cancel authentication, and verify no payment was sent. Tap the locked amount and verify the payment review stays open. Use Back to close the payment sheet. Reopen the unpaid period and verify it waits at payment review without a completed Subscribe swipe or automatic-payment progress. Explicitly authorize it and verify one payment, not two. Repeat with a USDT-only proposal whose amount exceeds the payer's balance including fees. Swipe to subscribe and verify an insufficient-funds or fee-collection error prevents payment. Close and reopen the unpaid period; verify it remains payable and no payment was recorded. Return to the Overview tab with both the Bitcoin and USD subscriptions active. Verify Monthly Cost shows one total in the selected display currency, using Bitkit's exchange rate, aligned with Active and Created rather than separate stacked amounts. From 72814ca6b703162c0124b859503bf26ef5cd0937 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 19 Oct 2026 08:49:25 -0600 Subject: [PATCH 05/10] test: verify subscription success navigation without ocr --- .../PaykitPaymentRequestServiceTests.swift | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 7a246ee02..e95755f62 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -220,6 +220,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } func testInitialUsdtSuccessCannotNavigateBackToPaidAmount() async throws { + let previousSwipeBack = SwipeBackState.allowSwipeBack + SwipeBackState.allowSwipeBack = true + defer { SwipeBackState.allowSwipeBack = previousSwipeBack } let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let mnemonic = "test test test test test test test test test test test junk" @@ -240,9 +243,17 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { defer { window.isHidden = true; window.rootViewController = nil } await fulfillment(of: [sent], timeout: 3) try await Task.sleep(for: .milliseconds(1500)) - let labels = try initialPaymentSnapshot(window, name: "subscription-success") - XCTAssertTrue(labels.contains(t("subscriptions__subscribed")), "\(labels)") - XCTAssertFalse(labels.contains("Back"), "\(labels)") + _ = try initialPaymentSnapshot(window, name: "subscription-success") + var controllers = try [XCTUnwrap(window.rootViewController)] + var navigation: UINavigationController? + while !controllers.isEmpty { + let controller = controllers.removeFirst() + if let candidate = controller as? UINavigationController { navigation = candidate; break } + controllers.append(contentsOf: controller.children) + } + let stack = try XCTUnwrap(navigation) + XCTAssertEqual(stack.viewControllers.count, 2) + XCTAssertTrue(stack.isNavigationBarHidden) XCTAssertFalse(SwipeBackState.allowSwipeBack) } From a3d15fb59e4f7b938a4320e02fcf521202cffbde Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 19 Oct 2026 15:39:10 -0600 Subject: [PATCH 06/10] fix: show payable subscription period status --- .../PaymentRequests/PaymentRequestsView.swift | 8 +++++-- .../PaykitPaymentRequestServiceTests.swift | 24 +++++++++++++++++++ changelog.d/next/981.fixed.md | 2 +- .../subscriptions/review-and-subscribe.xml | 2 +- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift index 8bd4e7b63..4e214748b 100644 --- a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift +++ b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift @@ -20,7 +20,7 @@ enum PaymentRequestDisplay { isActionable: Bool, now: Date = Date() ) -> String { - if request.isExpired(at: now), request.lifecycleState == .proposed { + if request.isExpired(at: now), request.lifecycleState == .proposed || request.lifecycleState == .activeRecurring { return "wallet__payment_request_status_expired" } @@ -46,7 +46,11 @@ enum PaymentRequestDisplay { return "wallet__payment_request_status_proof_submitted" case .recoveryRequired: return "wallet__payment_request_status_action_required" - case .invalidConflict, .activeRecurring, .unknown: + case .activeRecurring: + return request.billingPeriod != nil && request.direction == .incoming && isActionable + ? "wallet__payment_request_waiting" + : "wallet__payment_request_status_unavailable" + case .invalidConflict, .unknown: return "wallet__payment_request_status_unavailable" } } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index e95755f62..21b97e38c 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -4958,6 +4958,30 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ) } + func testUnpaidSubscriptionPeriodShowsWaitingOnlyWhilePayable() throws { + let start = "2027-01-01T00:00:00Z" + let now = try XCTUnwrap(PaykitPaymentRequest.parseDate(start)) + let period = try XCTUnwrap(PaykitBillingPeriod(sdkPeriod: BillingPeriod( + startsAt: start, endsAt: "2027-02-01T00:00:00Z" + ))) + let record = try paymentRequestRecord( + state: .activeRecurring, amount: "2", asset: "usdt", paymentDeadline: .periodStart(seconds: 60), + recurrence: PaymentRequestRecurrence(every: 1, unit: "month", startsAt: start, anchor: start, endsAt: nil), + endpoints: ["usdt-arbitrum"] + ) + let subscription = try XCTUnwrap(PaykitSubscription(record: record)) + let request = PaykitPaymentRequest(subscription: subscription, billingPeriod: period, lifecycleState: .activeRecurring) + + XCTAssertEqual(PaymentRequestDisplay.statusKey(for: request, isActionable: true, now: now), "wallet__payment_request_waiting") + XCTAssertEqual(PaymentRequestDisplay.statusKey(for: request, isActionable: false, now: now), "wallet__payment_request_status_unavailable") + XCTAssertEqual( + PaymentRequestDisplay.statusKey(for: request, isActionable: true, now: now.addingTimeInterval(61)), + "wallet__payment_request_status_expired" + ) + let paidRequest = request.updatingLifecycleState(.proofSubmitted, paymentProofKind: .usdt) + XCTAssertEqual(PaymentRequestDisplay.statusKey(for: paidRequest, isActionable: false), "wallet__payment_request_status_proof_submitted") + } + func testBelowMinimumOnchainRequestShowsMinimumAndRemainsRetryable() async throws { for nodeIsRunning in [false, true] { for amount in ["0.00000001", "0.000001", "0.00000546"] { diff --git a/changelog.d/next/981.fixed.md b/changelog.d/next/981.fixed.md index e6879f3e3..c37bfc22d 100644 --- a/changelog.d/next/981.fixed.md +++ b/changelog.d/next/981.fixed.md @@ -1 +1 @@ -Eligible initial USDT subscription payments now continue after subscribing without a second payment swipe, while retaining payment authentication and safety checks. +Eligible initial USDT subscription payments continue after subscribing without a second swipe while retaining authentication and safety checks, and unpaid periods show when they can be retried. diff --git a/journeys/subscriptions/review-and-subscribe.xml b/journeys/subscriptions/review-and-subscribe.xml index d3493359a..e840086a7 100644 --- a/journeys/subscriptions/review-and-subscribe.xml +++ b/journeys/subscriptions/review-and-subscribe.xml @@ -34,7 +34,7 @@ Verify the requested amount is $2, USDT is the only payment method and the swipe knob is USDT teal; swipe to subscribe Verify the Review and Subscribe layout shows payment preparation without another amount-entry step or payment swipe. Complete any enabled PIN or biometric authentication and required safety warnings. Wait for Subscribed and verify no Back button or swipe-back returns to the paid period; then tap Close. Verify the first period has only one payment in activity and is no longer offered for payment. Reopen the subscription and verify its next billing period remains scheduled. - Have the creator send another USDT-only proposal. Enable PIN for payments before accepting it. Swipe to subscribe, cancel authentication, and verify no payment was sent. Tap the locked amount and verify the payment review stays open. Use Back to close the payment sheet. Reopen the unpaid period and verify it waits at payment review without a completed Subscribe swipe or automatic-payment progress. Explicitly authorize it and verify one payment, not two. + Have the creator send another USDT-only proposal. Enable PIN for payments before accepting it. Swipe to subscribe, cancel authentication, and verify no payment was sent. Tap the locked amount and verify the payment review stays open. Use Back to close the payment sheet. Reopen the unpaid period from Payments and verify its detail shows Waiting for payment. Tap Pay, verify the fixed Amount screen, then tap Continue to reach payment review. Verify no completed Subscribe swipe or automatic-payment progress appears. Explicitly authorize payment and verify one payment, not two. Repeat with a USDT-only proposal whose amount exceeds the payer's balance including fees. Swipe to subscribe and verify an insufficient-funds or fee-collection error prevents payment. Close and reopen the unpaid period; verify it remains payable and no payment was recorded. Return to the Overview tab with both the Bitcoin and USD subscriptions active. Verify Monthly Cost shows one total in the selected display currency, using Bitkit's exchange rate, aligned with Active and Created rather than separate stacked amounts. From c7d87a05b54d86a6623d51ff42506346632fa40a Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 19 Oct 2026 21:29:00 -0600 Subject: [PATCH 07/10] fix: retain expired subscription periods --- .../PaykitPaymentRequestService.swift | 30 ++++-- .../PaykitPaymentRequestServiceTests.swift | 98 ++++++++++++++++--- changelog.d/next/981.fixed.md | 2 +- .../subscriptions/review-and-subscribe.xml | 1 + 4 files changed, 108 insertions(+), 23 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 959761672..734895b74 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -2553,19 +2553,22 @@ final class PaykitPaymentRequestManager { .flatMap { _, requests in requests.filter { $0.lifecycleState != .proofSubmitted && + !$0.isExpired(at: refreshDate) && !locallyCompletedRequestIds.contains($0.id) && (!locallyInFlightRequestIds.contains($0.id) || retainedHardwareRetries[$0.id] != nil) && !dismissedSubscriptionPaymentIds.contains($0.id) } } .sorted { ($0.billingPeriod?.startsAt ?? .distantFuture) < ($1.billingPeriod?.startsAt ?? .distantFuture) } - let recurringHistory = recurringRequestsBySubscription.flatMap { _, requests in + let recurringHistory = recurringRequestsBySubscription.flatMap { subscription, 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) + if let proofKind = locallyCompletedProofKinds[request.id] { + return request.updatingLifecycleState(.proofSubmitted, paymentProofKind: proofKind) + } + return subscription.lifecycleState == .activeRecurring && request.isExpired(at: refreshDate) ? request : nil } } let protectedRequests = pendingRequests.filter { @@ -2652,13 +2655,19 @@ final class PaykitPaymentRequestManager { let recurringRequests = subscriptionAcceptedAt[subscription.id].map { subscription.requests(through: date, acceptedAt: $0) } ?? [] + let paymentDate = now() pendingRequests.removeAll { $0.belongs(to: subscription) } if subscription.lifecycleState == .activeRecurring { - pendingRequests.append(contentsOf: recurringRequests.filter { $0.lifecycleState != .proofSubmitted }) + pendingRequests.append(contentsOf: recurringRequests.filter { + $0.lifecycleState != .proofSubmitted && !$0.isExpired(at: paymentDate) + }) pendingRequests.sort { ($0.createdAt ?? .distantFuture) < ($1.createdAt ?? .distantFuture) } } historyRequests.removeAll { $0.belongs(to: subscription) } - historyRequests.append(contentsOf: recurringRequests.filter { $0.lifecycleState == .proofSubmitted }) + historyRequests.append(contentsOf: recurringRequests.filter { + $0.lifecycleState == .proofSubmitted || + (subscription.lifecycleState == .activeRecurring && $0.isExpired(at: paymentDate)) + }) historyRequests.sort { ($0.createdAt ?? .distantPast) > ($1.createdAt ?? .distantPast) } await subscriptionNotificationScheduler.synchronize( subscriptions, @@ -2765,6 +2774,11 @@ final class PaykitPaymentRequestManager { let date = at ?? now() let subscriptionDate = subscriptionNow() recordRequestedPresentationExpiration(at: date, excluding: handledRequestedExpirationId) + let historyIds = Set(historyRequests.map(\.id)) + historyRequests.append(contentsOf: pendingRequests.filter { + $0.billingPeriod != nil && $0.isExpired(at: date) && !historyIds.contains($0.id) + }) + historyRequests.sort { ($0.createdAt ?? .distantPast) > ($1.createdAt ?? .distantPast) } pendingRequests.removeAll { $0.isExpired(at: date) } subscriptions = subscriptions.map { $0.withExpiredLifecycle(at: subscriptionDate) } presentedSubscriptionProposalIds.formIntersection( @@ -2834,6 +2848,10 @@ final class PaykitPaymentRequestManager { let requestDelays = pendingRequests.filter { $0.lifecycleState == .proposed }.compactMap(\.expiresAt).map { $0.timeIntervalSince(date) } + // Payment deadlines are inclusive; reconcile just after the boundary. + let paymentDeadlineDelays = pendingRequests.compactMap(\.paymentDeadline).map { + $0.date.addingTimeInterval(0.001).timeIntervalSince(date) + } let subscriptionDelays = subscriptions.filter { $0.isProposal || $0.lifecycleState == .activeRecurring }.flatMap { @@ -2841,7 +2859,7 @@ final class PaykitPaymentRequestManager { }.filter { $0 > subscriptionDate }.map { $0.timeIntervalSince(subscriptionDate) } - guard let nextDelay = (requestDelays + subscriptionDelays).min() else { return } + guard let nextDelay = (requestDelays + paymentDeadlineDelays + subscriptionDelays).min() else { return } let delay = max(0, nextDelay) expirationTask = Task { [weak self] in do { diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 21b97e38c..3ff9a9c4d 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -3471,6 +3471,30 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertNil(manager.subscriptionProposalForPresentation()) } + func testUnpaidSubscriptionDeadlineMovesToHistoryWithoutAnotherRefresh() async throws { + let start = timestamp(Date()) + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord( + state: .activeRecurring, paymentDeadline: .periodStart(seconds: 2), + recurrence: PaymentRequestRecurrence(every: 1, unit: "month", startsAt: start, anchor: start, endsAt: nil), + lastEventAt: start + )]) + let manager = PaykitPaymentRequestManager( + scheduleAcceptedRequestDelivery: { _ in }, + service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), + presentationStore: PaymentRequestPresentationMemoryStore(), + acceptanceStore: PaymentRequestPresentationMemoryStore(), + subscriptionStateStore: PaymentRequestSubscriptionStateMemoryStore(), + isAvailable: { true }, + logWarning: { _ in } + ) + defer { manager.clear() } + manager.activate(identity: "pubky\(String(repeating: "z", count: 52))") + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + try await waitUntil(timeout: .seconds(5)) { manager.pendingRequests.isEmpty } + XCTAssertEqual(manager.historyRequests, [request]) + } + func testCompletedSubscriptionPaymentAwaitingProofSubmissionIsNotOfferedAgain() async throws { let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) let recurrence = PaymentRequestRecurrence( @@ -3573,8 +3597,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } let center = PaykitSubscriptionNotificationCenterMock() let scheduler = PaykitSubscriptionNotificationScheduler(center: center) + let sdk = PaymentRequestSdkMock(records: records) let manager = paymentRequestManager( - sdk: PaymentRequestSdkMock(records: records), clock: PaymentRequestTestClock(now) + sdk: sdk, clock: PaymentRequestTestClock(now) ) await manager.refresh() @@ -3591,8 +3616,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(subscription.canCancel(at: now)) if subscription.isPayer { XCTAssertNotNil(subscription.paymentDueOnAcceptance(at: now)) } } - let paid = try XCTUnwrap(manager.historyRequests.first) - XCTAssertEqual(manager.historyRequests.count, 1) + let paid = try XCTUnwrap(manager.historyRequests.first { $0.lifecycleState == .proofSubmitted }) + XCTAssertEqual(manager.historyRequests.count, 2) + XCTAssertTrue(manager.historyRequests.contains { $0.id == unpaid.id && $0.isExpired(at: now) }) XCTAssertEqual(paid.lifecycleState, .proofSubmitted) XCTAssertEqual(paid.paymentProofKind, .lightning) XCTAssertEqual(manager.subscriptions.first { $0.isCreatedByUser }?.receivedPaymentRequests().count, 1) @@ -3603,6 +3629,14 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ) let pendingNotifications = await center.pendingIdentifiers XCTAssertFalse(pendingNotifications.isEmpty) + try await sdk.setRecords([paymentRequestRecord( + id: "deadline-\(PaymentRequestLocalRole.payer)", state: .canceled, + paymentDeadline: .periodStart(seconds: 3600), recurrence: recurrence, + lastEventAt: "2027-01-02T08:00:00Z", paymentProofs: [proof] + )]) + await manager.refresh() + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertEqual(manager.historyRequests.map(\.id), [paid.id], "Cancellation must not generate unpaid periods") } func testInFlightSubscriptionPaymentIsNotOfferedOrMarkedPaid() async throws { @@ -4958,28 +4992,60 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ) } - func testUnpaidSubscriptionPeriodShowsWaitingOnlyWhilePayable() throws { + func testExpiredSubscriptionPeriodRemainsInHistoryAcrossRefreshAndRestart() async throws { let start = "2027-01-01T00:00:00Z" - let now = try XCTUnwrap(PaykitPaymentRequest.parseDate(start)) - let period = try XCTUnwrap(PaykitBillingPeriod(sdkPeriod: BillingPeriod( - startsAt: start, endsAt: "2027-02-01T00:00:00Z" - ))) + let date = try XCTUnwrap(PaykitPaymentRequest.parseDate(start)) + let nextPeriod = try XCTUnwrap(PaykitPaymentRequest.parseDate("2027-02-01T00:00:00Z")) + let clock = PaymentRequestTestClock(date) + let store = PaymentRequestSubscriptionStateMemoryStore() let record = try paymentRequestRecord( state: .activeRecurring, amount: "2", asset: "usdt", paymentDeadline: .periodStart(seconds: 60), recurrence: PaymentRequestRecurrence(every: 1, unit: "month", startsAt: start, anchor: start, endsAt: nil), - endpoints: ["usdt-arbitrum"] + endpoints: ["usdt-arbitrum"], lastEventAt: start ) - let subscription = try XCTUnwrap(PaykitSubscription(record: record)) - let request = PaykitPaymentRequest(subscription: subscription, billingPeriod: period, lifecycleState: .activeRecurring) + let sdk = PaymentRequestSdkMock(records: [record]) + let manager = paymentRequestManager(sdk: sdk, clock: clock, subscriptionStateStore: store) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + XCTAssertEqual(PaymentRequestDisplay.statusKey(for: request, isActionable: true, now: clock.now()), "wallet__payment_request_waiting") - XCTAssertEqual(PaymentRequestDisplay.statusKey(for: request, isActionable: true, now: now), "wallet__payment_request_waiting") - XCTAssertEqual(PaymentRequestDisplay.statusKey(for: request, isActionable: false, now: now), "wallet__payment_request_status_unavailable") + clock.advance(by: 60) + manager.reconcileExpiredRequests() + XCTAssertEqual(manager.pendingRequests, [request], "The payment deadline is inclusive") + clock.advance(by: 1) + manager.reconcileExpiredRequests() + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertEqual(manager.historyRequests, [request]) XCTAssertEqual( - PaymentRequestDisplay.statusKey(for: request, isActionable: true, now: now.addingTimeInterval(61)), + try PaymentRequestDisplay.statusKey(for: XCTUnwrap(manager.historyRequests.first), isActionable: false, now: clock.now()), "wallet__payment_request_status_expired" ) - let paidRequest = request.updatingLifecycleState(.proofSubmitted, paymentProofKind: .usdt) - XCTAssertEqual(PaymentRequestDisplay.statusKey(for: paidRequest, isActionable: false), "wallet__payment_request_status_proof_submitted") + XCTAssertFalse(manager.requestPresentation(request)) + do { + try await manager.ensurePaymentAllowed(request) + XCTFail("Expired history must not authorize a payment") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestExpired) + } + await manager.refresh() + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertEqual(manager.historyRequests, [request]) + manager.clear() + XCTAssertTrue(manager.historyRequests.isEmpty) + + let restored = paymentRequestManager(sdk: sdk, clock: clock, subscriptionStateStore: store) + defer { restored.clear() } + await restored.refresh() + XCTAssertTrue(restored.pendingRequests.isEmpty) + XCTAssertEqual(restored.historyRequests, [request]) + clock.advance(by: nextPeriod.timeIntervalSince(clock.now())) + await restored.refresh() + let next = try XCTUnwrap(restored.pendingRequests.first) + XCTAssertEqual(restored.pendingRequests.count, 1) + XCTAssertNotEqual(next.id, request.id) + XCTAssertEqual(next.billingPeriod?.startsAt, nextPeriod) + XCTAssertFalse(next.isExpired(at: clock.now())) + XCTAssertEqual(restored.historyRequests, [request]) } func testBelowMinimumOnchainRequestShowsMinimumAndRemainsRetryable() async throws { diff --git a/changelog.d/next/981.fixed.md b/changelog.d/next/981.fixed.md index c37bfc22d..402f44359 100644 --- a/changelog.d/next/981.fixed.md +++ b/changelog.d/next/981.fixed.md @@ -1 +1 @@ -Eligible initial USDT subscription payments continue after subscribing without a second swipe while retaining authentication and safety checks, and unpaid periods show when they can be retried. +Eligible initial USDT subscription payments continue after subscribing without a second swipe while retaining authentication and safety checks. Unpaid periods show when they can be retried, and expired periods remain visible in payment history. diff --git a/journeys/subscriptions/review-and-subscribe.xml b/journeys/subscriptions/review-and-subscribe.xml index e840086a7..8d40366bb 100644 --- a/journeys/subscriptions/review-and-subscribe.xml +++ b/journeys/subscriptions/review-and-subscribe.xml @@ -36,6 +36,7 @@ Verify the first period has only one payment in activity and is no longer offered for payment. Reopen the subscription and verify its next billing period remains scheduled. Have the creator send another USDT-only proposal. Enable PIN for payments before accepting it. Swipe to subscribe, cancel authentication, and verify no payment was sent. Tap the locked amount and verify the payment review stays open. Use Back to close the payment sheet. Reopen the unpaid period from Payments and verify its detail shows Waiting for payment. Tap Pay, verify the fixed Amount screen, then tap Continue to reach payment review. Verify no completed Subscribe swipe or automatic-payment progress appears. Explicitly authorize payment and verify one payment, not two. Repeat with a USDT-only proposal whose amount exceeds the payer's balance including fees. Swipe to subscribe and verify an insufficient-funds or fee-collection error prevents payment. Close and reopen the unpaid period; verify it remains payable and no payment was recorded. + With an unpaid subscription period whose signed payment deadline is shortly ahead, open its detail and wait past the deadline. Verify it changes from Waiting for payment to Expired with no Pay action, and its row remains in Payments. Refresh and relaunch, then verify the same expired period remains visible once and cannot be paid. When the next billing period starts, verify only the new period offers Pay and the old period remains Expired. Return to the Overview tab with both the Bitcoin and USD subscriptions active. Verify Monthly Cost shows one total in the selected display currency, using Bitkit's exchange rate, aligned with Active and Created rather than separate stacked amounts. From 9450bdb3453c60c6e2356524fe8d1fbe575b1a71 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 19 Oct 2026 23:20:51 -0600 Subject: [PATCH 08/10] style: format activity row conditional body --- Bitkit/Views/Wallets/Activity/ActivityRow.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Bitkit/Views/Wallets/Activity/ActivityRow.swift b/Bitkit/Views/Wallets/Activity/ActivityRow.swift index 9b50cbc36..da5cd8594 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityRow.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityRow.swift @@ -101,7 +101,9 @@ struct ActivityRowContent: View { CaptionBText(subtitle, kerning: textKerning, lineHeight: subtitleLineHeight).lineLimit(stacked ? nil : 1) } .fixedSize(horizontal: false, vertical: true) - if !stacked { Spacer(minLength: 0) } + if !stacked { + Spacer(minLength: 0) + } amount() } .frame(maxWidth: stacked ? .infinity : nil, alignment: .leading) From 78cfdb75095cdc5be817fb51151bad4d88b6b3a0 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 20 Oct 2026 04:44:01 -0600 Subject: [PATCH 09/10] fix: show only paid periods in subscription details --- .../Subscriptions/SubscriptionsView.swift | 10 +++++++--- .../PaykitPaymentRequestServiceTests.swift | 18 +++++++++++++++++- changelog.d/next/981.fixed.md | 2 +- .../subscriptions/review-and-subscribe.xml | 2 +- 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift index b1a0f9a80..8c00191ca 100644 --- a/Bitkit/Views/Subscriptions/SubscriptionsView.swift +++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift @@ -230,6 +230,12 @@ struct SubscriptionsView: View { } } +func subscriptionPayments(subscription: PaykitSubscription, history: [PaykitPaymentRequest]) -> [PaykitPaymentRequest] { + subscription.isCreatedByUser + ? subscription.receivedPaymentRequests() + : history.filter { $0.belongs(to: subscription) && $0.lifecycleState == .proofSubmitted } +} + func subscriptionMonthlyCostSats(amounts: [PaykitAmount], rate: PaykitExchangeRate?, now: Date = Date()) -> Int? { var total = 0 for amount in amounts { @@ -509,9 +515,7 @@ struct SubscriptionDetailView: View { @ViewBuilder private func payments(_ subscription: PaykitSubscription) -> some View { - let payments = subscription.isCreatedByUser - ? subscription.receivedPaymentRequests() - : paymentRequests.historyRequests.filter { $0.belongs(to: subscription) } + let payments = subscriptionPayments(subscription: subscription, history: paymentRequests.historyRequests) if !payments.isEmpty { VStack(alignment: .leading, spacing: 12) { CaptionMText(t("subscriptions__payments").localizedUppercase, textColor: .white64) diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 863262bb3..7ba9f7121 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -3640,7 +3640,11 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(manager.historyRequests.contains { $0.id == unpaid.id && $0.isExpired(at: now) }) XCTAssertEqual(paid.lifecycleState, .proofSubmitted) XCTAssertEqual(paid.paymentProofKind, .lightning) - XCTAssertEqual(manager.subscriptions.first { $0.isCreatedByUser }?.receivedPaymentRequests().count, 1) + XCTAssertEqual(subscriptionPayments(subscription: payerSubscription, history: manager.historyRequests), [paid]) + let creatorSubscription = try XCTUnwrap(manager.subscriptions.first { $0.isCreatedByUser }) + let received = subscriptionPayments(subscription: creatorSubscription, history: manager.historyRequests) + XCTAssertEqual(received, creatorSubscription.receivedPaymentRequests()) + XCTAssertEqual(received.count, 1) await scheduler.synchronize( manager.subscriptions, acceptedAt: Dictionary(uniqueKeysWithValues: manager.subscriptions.map { ($0.id, PaykitPreciseInstant(date: now)) }), @@ -5037,6 +5041,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { manager.reconcileExpiredRequests() XCTAssertTrue(manager.pendingRequests.isEmpty) XCTAssertEqual(manager.historyRequests, [request]) + XCTAssertTrue(try subscriptionPayments( + subscription: XCTUnwrap(manager.subscriptions.first), history: manager.historyRequests + ).isEmpty) XCTAssertEqual( try PaymentRequestDisplay.statusKey(for: XCTUnwrap(manager.historyRequests.first), isActionable: false, now: clock.now()), "wallet__payment_request_status_expired" @@ -5051,6 +5058,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { await manager.refresh() XCTAssertTrue(manager.pendingRequests.isEmpty) XCTAssertEqual(manager.historyRequests, [request]) + XCTAssertTrue(try subscriptionPayments( + subscription: XCTUnwrap(manager.subscriptions.first), history: manager.historyRequests + ).isEmpty) manager.clear() XCTAssertTrue(manager.historyRequests.isEmpty) @@ -5059,6 +5069,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { await restored.refresh() XCTAssertTrue(restored.pendingRequests.isEmpty) XCTAssertEqual(restored.historyRequests, [request]) + XCTAssertTrue(try subscriptionPayments( + subscription: XCTUnwrap(restored.subscriptions.first), history: restored.historyRequests + ).isEmpty) clock.advance(by: nextPeriod.timeIntervalSince(clock.now())) await restored.refresh() let next = try XCTUnwrap(restored.pendingRequests.first) @@ -5067,6 +5080,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(next.billingPeriod?.startsAt, nextPeriod) XCTAssertFalse(next.isExpired(at: clock.now())) XCTAssertEqual(restored.historyRequests, [request]) + XCTAssertTrue(try subscriptionPayments( + subscription: XCTUnwrap(restored.subscriptions.first), history: restored.historyRequests + ).isEmpty) } func testBelowMinimumOnchainRequestShowsMinimumAndRemainsRetryable() async throws { diff --git a/changelog.d/next/981.fixed.md b/changelog.d/next/981.fixed.md index 402f44359..41d7bc26c 100644 --- a/changelog.d/next/981.fixed.md +++ b/changelog.d/next/981.fixed.md @@ -1 +1 @@ -Eligible initial USDT subscription payments continue after subscribing without a second swipe while retaining authentication and safety checks. Unpaid periods show when they can be retried, and expired periods remain visible in payment history. +Eligible initial USDT subscription payments continue after subscribing without a second swipe while retaining authentication and safety checks, with retryable unpaid periods, expired requests retained in payment history, and only paid periods shown in subscription details. diff --git a/journeys/subscriptions/review-and-subscribe.xml b/journeys/subscriptions/review-and-subscribe.xml index 8d40366bb..617a8af06 100644 --- a/journeys/subscriptions/review-and-subscribe.xml +++ b/journeys/subscriptions/review-and-subscribe.xml @@ -36,7 +36,7 @@ Verify the first period has only one payment in activity and is no longer offered for payment. Reopen the subscription and verify its next billing period remains scheduled. Have the creator send another USDT-only proposal. Enable PIN for payments before accepting it. Swipe to subscribe, cancel authentication, and verify no payment was sent. Tap the locked amount and verify the payment review stays open. Use Back to close the payment sheet. Reopen the unpaid period from Payments and verify its detail shows Waiting for payment. Tap Pay, verify the fixed Amount screen, then tap Continue to reach payment review. Verify no completed Subscribe swipe or automatic-payment progress appears. Explicitly authorize payment and verify one payment, not two. Repeat with a USDT-only proposal whose amount exceeds the payer's balance including fees. Swipe to subscribe and verify an insufficient-funds or fee-collection error prevents payment. Close and reopen the unpaid period; verify it remains payable and no payment was recorded. - With an unpaid subscription period whose signed payment deadline is shortly ahead, open its detail and wait past the deadline. Verify it changes from Waiting for payment to Expired with no Pay action, and its row remains in Payments. Refresh and relaunch, then verify the same expired period remains visible once and cannot be paid. When the next billing period starts, verify only the new period offers Pay and the old period remains Expired. + With an unpaid subscription period whose signed payment deadline is shortly ahead, open its detail and wait past the deadline. Verify it changes from Waiting for payment to Expired with no Pay action, and its row remains in Payments. Refresh and relaunch, then verify the same expired period remains visible once and cannot be paid. Open the subscription detail and verify the unpaid expired period is absent from its Payments section while previously paid periods remain. When the next billing period starts, verify only the new period offers Pay, the old period remains Expired in payment-request history, and the subscription detail still lists only paid periods. Return to the Overview tab with both the Bitcoin and USD subscriptions active. Verify Monthly Cost shows one total in the selected display currency, using Bitkit's exchange rate, aligned with Active and Created rather than separate stacked amounts. From cdaf0010ea48b4cdc9f7e9d33ebd5018ff20d2e6 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 21 Oct 2026 12:06:33 -0600 Subject: [PATCH 10/10] test: cover initial payment authorization navigation --- .../PaykitPaymentRequestServiceTests.swift | 130 +++++++++--------- 1 file changed, 66 insertions(+), 64 deletions(-) diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 797494f3d..1a9e0c74f 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -169,62 +169,62 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } } - func testInitialUsdtPinAuthorizationSurvivesNavigationAndRemainsOneShot() async throws { - snapshotAppDefaultsDomain() - let settings = SettingsViewModel.shared - let previousPinEnabled = settings.pinEnabled - let previousPin = try Bitkit.Keychain.load(key: .securityPin) - let previousSwipeBack = SwipeBackState.allowSwipeBack - addTeardownBlock { @MainActor in - settings.pinEnabled = previousPinEnabled - SwipeBackState.allowSwipeBack = previousSwipeBack - if let previousPin { - try Bitkit.Keychain.upsert(key: .securityPin, data: previousPin) - } else { - try Bitkit.Keychain.delete(key: .securityPin) - } - } - try Bitkit.Keychain.upsert(key: .securityPin, data: Data("1234".utf8)) - settings.pinEnabled = true - settings.requirePinForPayments = true - settings.useBiometrics = false - settings.pinFailedAttempts = 0 - - for verifiesPin in [false, true] { + func testInitialUsdtAuthorizationSurvivesNavigationAndRemainsOneShot() async throws { + for authorized in [false, true] { var quoteCount = 0 + var authorizationCount = 0 var sendCount = 0 - let sent = expectation(description: "PIN-authorized initial payment") - sent.isInverted = !verifiesPin + var authorization: CheckedContinuation? + var resumedAuthorizationWasCancelled: [Bool] = [] + let navigation = InitialUsdtPaymentTestNavigation() let manager = UsdtWalletManager(rpcUrl: nil, bundlerUrl: nil, loadCredentials: { throw UsdtError.InvalidCredentials }) - let window = try hostInitialUsdtPayment(manager: manager, quotePayment: { _, _, _ in + let window = try hostInitialUsdtPayment(manager: manager, navigation: navigation, quotePayment: { _, _, _ in quoteCount += 1 return self.initialUsdtQuote + }, authenticatePayment: { + authorizationCount += 1 + guard authorizationCount == 1 else { return false } + let result = await withCheckedContinuation { continuation in + authorization = continuation + navigation.path = [1] + } + resumedAuthorizationWasCancelled.append(Task.isCancelled) + return result }, sendPayment: { _ in try Task.checkCancellation() sendCount += 1 - sent.fulfill() }) - defer { window.isHidden = true; window.rootViewController = nil } + defer { + authorization?.resume(returning: false) + authorization = nil + window.isHidden = true + window.rootViewController = nil + } try await waitUntil { - self.initialPaymentNavigation(window)?.viewControllers.count == 2 && !manager.isSendPresented + navigation.authorizationVisible && !manager.isSendPresented } - let navigation = try XCTUnwrap(initialPaymentNavigation(window)) - let pinView = try XCTUnwrap(navigation.topViewController?.view) - for identifier in verifiesPin ? ["N1", "N2", "N3", "N4"] : ["NavigationBack"] { - try await waitUntil { self.initialPaymentAccessibilityElement(identifier, in: pinView) != nil } - let button = try XCTUnwrap(initialPaymentAccessibilityElement(identifier, in: pinView)) - XCTAssertTrue(button.accessibilityActivate(), "Could not activate \(identifier)") - try await Task.sleep(for: .milliseconds(50)) + XCTAssertEqual(quoteCount, 1) + XCTAssertEqual(authorizationCount, 1) + XCTAssertEqual(sendCount, 0) + navigation.path.removeAll() + try await waitUntil { !navigation.authorizationVisible && manager.isSendPresented } + let continuation = try XCTUnwrap(authorization) + authorization = nil + continuation.resume(returning: authorized) + try await waitUntil { !resumedAuthorizationWasCancelled.isEmpty } + XCTAssertEqual(resumedAuthorizationWasCancelled, [false]) + if authorized { + try await waitUntil { sendCount == 1 } } - await fulfillment(of: [sent], timeout: verifiesPin ? 3 : 0.2) - try await waitUntil { navigation.viewControllers.count == 1 && manager.isSendPresented } + let settings = SettingsViewModel.shared settings.usdtPreference = .disabled try await Task.sleep(for: .milliseconds(100)) settings.usdtPreference = .enabled try await Task.sleep(for: .milliseconds(100)) - XCTAssertEqual(navigation.viewControllers.count, 1, "Returning from PIN must not authorize again") + XCTAssertTrue(navigation.path.isEmpty, "Returning from authorization must not authorize again") XCTAssertEqual(quoteCount, 1) - XCTAssertEqual(sendCount, verifiesPin ? 1 : 0) + XCTAssertEqual(authorizationCount, 1) + XCTAssertEqual(sendCount, authorized ? 1 : 0) } } @@ -323,26 +323,6 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { return nil } - private func initialPaymentAccessibilityElement(_ identifier: String, in root: NSObject) -> NSObject? { - var elements = [root] - var visited: Set = [] - while !elements.isEmpty { - let element = elements.removeFirst() - guard visited.insert(ObjectIdentifier(element)).inserted else { continue } - if (element as? UIAccessibilityIdentification)?.accessibilityIdentifier == identifier { - return element - } - if let view = element as? UIView { - elements.append(contentsOf: view.subviews) - } - let count = element.accessibilityElementCount() - if count > 0, count != NSNotFound { - elements.append(contentsOf: (0 ..< count).compactMap { element.accessibilityElement(at: $0) as? NSObject }) - } - } - return nil - } - private func initialPaymentSnapshot(_ window: UIWindow, name: String) throws -> [String] { let view = try XCTUnwrap(window.rootViewController?.view) let image = UIGraphicsImageRenderer(bounds: view.bounds).image { _ in @@ -368,6 +348,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { automatic: Bool = true, manager: UsdtWalletManager? = nil, pushed: Bool = false, + navigation: InitialUsdtPaymentTestNavigation? = nil, quotePayment: @escaping (String, String, UsdtDestination) async throws -> UsdtQuote, authenticatePayment: (() async -> Bool)? = nil, sendPayment: @escaping (UsdtQuote) async throws -> Void @@ -417,13 +398,27 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } let window = UIWindow(windowScene: scene) window.frame = CGRect(x: 0, y: 0, width: 390, height: 844) - window.rootViewController = UIHostingController(rootView: NavigationStack(path: .constant(pushed ? [1] : [])) { - if pushed { - Color.black.navigationDestination(for: Int.self) { _ in view } + let root = Group { + if let navigation { + @Bindable var navigation = navigation + NavigationStack(path: $navigation.path) { + view.navigationDestination(for: Int.self) { _ in + Color.black + .onAppear { navigation.authorizationVisible = true } + .onDisappear { navigation.authorizationVisible = false } + } + } } else { - view + NavigationStack(path: .constant(pushed ? [1] : [])) { + if pushed { + Color.black.navigationDestination(for: Int.self) { _ in view } + } else { + view + } + } } - }) + } + window.rootViewController = UIHostingController(rootView: root) window.makeKeyAndVisible() window.rootViewController?.view.layoutIfNeeded() return window @@ -10230,3 +10225,10 @@ private final class PaymentRequestSendWallet: WalletViewModel { selectedFeeRateSatsPerVByte = 2 } } + +@Observable +@MainActor +private final class InitialUsdtPaymentTestNavigation { + var path: [Int] = [] + var authorizationVisible = false +}