diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index e28da5b41..6d1ec50ad 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -2601,19 +2601,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 { @@ -2700,13 +2703,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, @@ -2815,6 +2824,11 @@ final class PaykitPaymentRequestManager { let subscriptionDate = subscriptionNow() recordRequestedPresentationExpiration(at: date, excluding: handledRequestedExpirationId) recordSilentExpirations(at: date, excluding: [handledRequestedExpirationId, openedRequestId].compactMap { $0 }) + 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( @@ -2898,6 +2912,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 { @@ -2905,7 +2923,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/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index b273656da..f7e395ff1 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -37,6 +37,23 @@ 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, + Set(request.acceptedPaymentEndpointIdentifiers) == [PublicPaykitService.MethodId.usdtArbitrum.rawValue], + !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/PaymentRequests/PaymentRequestsView.swift b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift index 2bdf50a61..5ed35fa2f 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/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift index f5e64aad8..90612ff6a 100644 --- a/Bitkit/Views/Subscriptions/SubscriptionsView.swift +++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift @@ -244,6 +244,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 { @@ -523,9 +529,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) @@ -785,8 +789,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 @@ -922,7 +934,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 fef0e36e0..f1e7f9f5e 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -958,7 +958,13 @@ struct SendSheet: View { } guard app.ownsContactPaymentContext(context) else { throw PaykitPaymentRequestError.requestUnavailable } } - }, onBack: { flow.navigationPath.removeLast() }, onClose: { sheets.hideSheet() }) + }, onBack: { + if flow.navigationPath.isEmpty { + sheets.hideSheet() + } else { + flow.navigationPath.removeLast() + } + }, onClose: { sheets.hideSheet() }) case let .usdtAddress(recipient, amount): UsdtSendView(onDetails: { id in flow.usdtDetails = id @@ -1448,7 +1454,13 @@ struct SendSheet: View { return } } - flow.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 + flow.pendingEmbeddedRetryRoute = .usdt(recipient: recipient, amount: terms.amount.value) + } else { + flow.pendingEmbeddedRetryRoute = context.requiresAssetSelection ? .amount : app.lnurlPayData == nil ? .confirm : .lnurlPayConfirm + } flow.hasValidatedAfterSync = false guard !shouldShowSyncOverlay else { return } validatePaymentAfterSync() diff --git a/Bitkit/Views/Wallets/UsdtSendView.swift b/Bitkit/Views/Wallets/UsdtSendView.swift index 1e0cdff2b..8aa977095 100644 --- a/Bitkit/Views/Wallets/UsdtSendView.swift +++ b/Bitkit/Views/Wallets/UsdtSendView.swift @@ -10,6 +10,8 @@ struct UsdtSendView: View { /// Shown inside the send sheet's navigation stack: back from the amount step returns to the address step. var hosted = 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)? @@ -35,6 +37,8 @@ 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 automaticPayment = false @State private var paymentComplete = false @State private var showDetails = false @State private var showFullRecipient = false @@ -52,6 +56,8 @@ struct UsdtSendView: View { embedded: Bool = false, hosted: 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 @@ -62,6 +68,8 @@ struct UsdtSendView: View { self.embedded = embedded self.hosted = hosted self.paymentContext = paymentContext + self.quotePayment = quotePayment + self.authenticatePayment = authenticatePayment self.sendPayment = sendPayment self.onBack = onBack self.onClose = onClose @@ -135,12 +143,45 @@ struct UsdtSendView: View { dismiss() } }, onEnable: { settings.usdtPreference = .enabled }) + } else if paymentContext?.isInitialSubscriptionPayment == true, submitted, !busy, submittedStatus == .confirmed { + SubscriptionSuccessView(paymentProofKind: .usdt) { + if let onClose { + onClose() + } else { + dismiss() + } + } + .navigationBarHidden(true) + .allowSwipeBack(false) } else if embedded || hosted { 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 automaticPayment, busy { + InitialSubscriptionPaymentProgress() + } + } .preference(key: SheetBackgroundPreferenceKey.self, value: .gray7) .onAppear { usdt.isSendPresented = true } .onDisappear { usdt.isSendPresented = false; preparedQuote = nil; resolveWarning(proceed: false) } @@ -153,10 +194,16 @@ struct UsdtSendView: View { editingAmount, !showingNetworks, !initialRecipient.isEmpty, recipient == initialRecipient, amount == initialAmount, destination == .arbitrum, (try? parsePaykitAmountInput(asset: .usdt, value: amount)) != nil else { return } + 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 { + // PIN navigation can cancel the view task while authorization is awaiting its result. + Task { @MainActor in try? await confirmPayment() } + } } } @@ -197,24 +244,6 @@ struct UsdtSendView: View { resolvePin(verified: false) } } - .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: .constant(warning != nil), presenting: warning) { _ in Button(t("common__cancel"), role: .cancel) { resolveWarning(proceed: false) } @@ -365,6 +394,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 } @@ -434,11 +464,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) @@ -474,6 +501,7 @@ struct UsdtSendView: View { title: t("wallet__send_swipe"), accentColor: .usdtAccent, isDisabled: busy, isLoading: busy, isSubmitted: submitted, isComplete: paymentComplete, swipeProgress: $swipeProgress ) { + automaticPayment = false try await confirmPayment() if !submitted { throw CancellationError() @@ -661,7 +689,11 @@ 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 } @@ -733,6 +765,15 @@ struct UsdtSendView: View { private func authenticateAndSend() async throws { 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 { let verified = await withCheckedContinuation { continuation in diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index c00befe1d..2fb81a4bc 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 @@ -54,6 +55,375 @@ 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 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 testInitialUsdtAuthorizationSurvivesNavigationAndRemainsOneShot() async throws { + for authorized in [false, true] { + var quoteCount = 0 + var authorizationCount = 0 + var sendCount = 0 + 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, 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 + }) + defer { + authorization?.resume(returning: false) + authorization = nil + window.isHidden = true + window.rootViewController = nil + } + try await waitUntil { + navigation.authorizationVisible && !manager.isSendPresented + } + 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 } + } + let settings = SettingsViewModel.shared + settings.usdtPreference = .disabled + try await Task.sleep(for: .milliseconds(100)) + settings.usdtPreference = .enabled + try await Task.sleep(for: .milliseconds(100)) + XCTAssertTrue(navigation.path.isEmpty, "Returning from authorization must not authorize again") + XCTAssertEqual(quoteCount, 1) + XCTAssertEqual(authorizationCount, 1) + XCTAssertEqual(sendCount, authorized ? 1 : 0) + } + } + + 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)) + } + + 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 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" + 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)) + _ = try initialPaymentSnapshot(window, name: "subscription-success") + let stack = try XCTUnwrap(initialPaymentNavigation(window)) + XCTAssertEqual(stack.viewControllers.count, 2) + XCTAssertTrue(stack.isNavigationBarHidden) + XCTAssertFalse(SwipeBackState.allowSwipeBack) + } + + private func initialPaymentNavigation(_ window: UIWindow) -> UINavigationController? { + var controllers = window.rootViewController.map { [$0] } ?? [] + while !controllers.isEmpty { + let controller = controllers.removeFirst() + if let navigation = controller as? UINavigationController { + return navigation + } + controllers.append(contentsOf: controller.children) + } + 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 + 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, + expiresAt: UInt64(Date().timeIntervalSince1970) + 60) + } + + private func hostInitialUsdtPayment( + 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 + ) 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: automatic ? [endpoint.methodId.rawValue] : [ + endpoint.methodId.rawValue, + PublicPaykitService.MethodId.bitcoinLightningBolt11.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]) + 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) + .environment(manager) + .environment(PaykitPaymentRequestManager()) + .environmentObject(AppViewModel()) + .environmentObject(ContactsManager()) + .environmentObject(CurrencyViewModel(currencyService: OfflineCurrencyService())) + .environmentObject(settings) + .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) + 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 { + 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 + } + func testMonthlyCostCombinesAssetsUsingTheWalletRate() { let now = Date() let amounts = [ @@ -3211,6 +3581,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( @@ -3313,8 +3707,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() @@ -3333,11 +3728,16 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { 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) + 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)) }), @@ -3345,6 +3745,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 { @@ -4702,6 +5110,74 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ) } + func testExpiredSubscriptionPeriodRemainsInHistoryAcrossRefreshAndRestart() async throws { + let start = "2027-01-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"], lastEventAt: start + ) + 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") + + 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]) + 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" + ) + 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]) + XCTAssertTrue(try subscriptionPayments( + subscription: XCTUnwrap(manager.subscriptions.first), history: manager.historyRequests + ).isEmpty) + 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]) + 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) + 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]) + XCTAssertTrue(try subscriptionPayments( + subscription: XCTUnwrap(restored.subscriptions.first), history: restored.historyRequests + ).isEmpty) + } + func testBelowMinimumOnchainRequestShowsMinimumAndRemainsRetryable() async throws { for nodeIsRunning in [false, true] { for amount in ["0.00000001", "0.000001", "0.00000546"] { @@ -9903,3 +10379,10 @@ private final class PaymentRequestSendWallet: WalletViewModel { selectedFeeRateSatsPerVByte = 2 } } + +@Observable +@MainActor +private final class InitialUsdtPaymentTestNavigation { + var path: [Int] = [] + var authorizationVisible = false +} diff --git a/changelog.d/next/981.fixed.md b/changelog.d/next/981.fixed.md new file mode 100644 index 000000000..41d7bc26c --- /dev/null +++ b/changelog.d/next/981.fixed.md @@ -0,0 +1 @@ +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 0dd55c7f6..617a8af06 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,11 @@ 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 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 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. 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.