From 0701eee31f94c49e704b8653920a54c3f3a1ddb9 Mon Sep 17 00:00:00 2001 From: Lucas Fischer Date: Sun, 26 Jul 2026 14:55:48 +0800 Subject: [PATCH] feat(checkout): add AmoreCheckout for in-app Stripe checkout --- Makefile | 1 + Package.swift | 21 + .../Checkout/AmoreCheckout+Flow.swift | 226 +++++ .../Checkout/AmoreCheckout+Navigation.swift | 34 + .../Checkout/AmoreCheckout+Recovery.swift | 94 ++ .../Checkout/AmoreCheckout.swift | 103 +++ .../Checkout/CheckoutState.swift | 33 + .../Checkout/LicenseActivating.swift | 10 + .../Client/CheckoutClientError.swift | 22 + .../Client/CheckoutSessionClient.swift | 86 ++ .../Documentation.docc/Custom Checkout UI.md | 88 ++ .../Documentation.docc/Getting Started.md | 103 +++ .../AmoreCheckout/Documentation.docc/Index.md | 39 + .../Documentation.docc/images/heart.svg | 6 + .../Documentation.docc/theme-settings.json | 35 + .../Models/CheckoutCompletion.swift | 35 + .../AmoreCheckout/Models/CheckoutError.swift | 56 ++ .../AmoreCheckout/Models/CheckoutResult.swift | 14 + .../Models/CheckoutSession.swift | 17 + .../Models/CheckoutSessionStatus.swift | 15 + .../Persistence/PendingCheckoutStore.swift | 51 ++ .../AmoreCheckout/UI/AmoreCheckoutView.swift | 195 +++++ .../UI/CheckoutCompletedView.swift | 127 +++ .../UI/CheckoutFailureView.swift | 119 +++ .../UI/CheckoutPageFailureView.swift | 50 ++ Sources/AmoreCheckout/UI/CheckoutSheet.swift | 72 ++ .../AmoreCheckout/UI/CheckoutWebView.swift | 101 +++ Sources/AmoreCheckout/UI/LicenseKeyView.swift | 46 + .../AmoreCheckout/UI/PreviewFixtures.swift | 41 + .../AmoreCheckout/UI/View+AmoreCheckout.swift | 117 +++ .../AmoreLicensing/Errors/AmoreError.swift | 2 +- .../AmoreLicensing/Errors/ClientError.swift | 2 +- .../AmoreLicensing/Errors/NetworkError.swift | 2 +- .../Errors/TokenStoreError.swift | 2 +- .../Documentation.docc/Getting Started.md | 14 +- Sources/AmoreStore/Models/Product.swift | 7 +- .../CheckoutFlowTests.swift | 828 ++++++++++++++++++ .../CheckoutModelTests.swift | 116 +++ .../CheckoutRecoveryTests.swift | 295 +++++++ .../CheckoutResumeTests.swift | 428 +++++++++ .../CheckoutSessionClientTests.swift | 141 +++ Tests/AmoreCheckoutTests/Mocks.swift | 138 +++ .../PendingCheckoutStoreTests.swift | 69 ++ 43 files changed, 3994 insertions(+), 7 deletions(-) create mode 100644 Sources/AmoreCheckout/Checkout/AmoreCheckout+Flow.swift create mode 100644 Sources/AmoreCheckout/Checkout/AmoreCheckout+Navigation.swift create mode 100644 Sources/AmoreCheckout/Checkout/AmoreCheckout+Recovery.swift create mode 100644 Sources/AmoreCheckout/Checkout/AmoreCheckout.swift create mode 100644 Sources/AmoreCheckout/Checkout/CheckoutState.swift create mode 100644 Sources/AmoreCheckout/Checkout/LicenseActivating.swift create mode 100644 Sources/AmoreCheckout/Client/CheckoutClientError.swift create mode 100644 Sources/AmoreCheckout/Client/CheckoutSessionClient.swift create mode 100644 Sources/AmoreCheckout/Documentation.docc/Custom Checkout UI.md create mode 100644 Sources/AmoreCheckout/Documentation.docc/Getting Started.md create mode 100644 Sources/AmoreCheckout/Documentation.docc/Index.md create mode 100644 Sources/AmoreCheckout/Documentation.docc/images/heart.svg create mode 100644 Sources/AmoreCheckout/Documentation.docc/theme-settings.json create mode 100644 Sources/AmoreCheckout/Models/CheckoutCompletion.swift create mode 100644 Sources/AmoreCheckout/Models/CheckoutError.swift create mode 100644 Sources/AmoreCheckout/Models/CheckoutResult.swift create mode 100644 Sources/AmoreCheckout/Models/CheckoutSession.swift create mode 100644 Sources/AmoreCheckout/Models/CheckoutSessionStatus.swift create mode 100644 Sources/AmoreCheckout/Persistence/PendingCheckoutStore.swift create mode 100644 Sources/AmoreCheckout/UI/AmoreCheckoutView.swift create mode 100644 Sources/AmoreCheckout/UI/CheckoutCompletedView.swift create mode 100644 Sources/AmoreCheckout/UI/CheckoutFailureView.swift create mode 100644 Sources/AmoreCheckout/UI/CheckoutPageFailureView.swift create mode 100644 Sources/AmoreCheckout/UI/CheckoutSheet.swift create mode 100644 Sources/AmoreCheckout/UI/CheckoutWebView.swift create mode 100644 Sources/AmoreCheckout/UI/LicenseKeyView.swift create mode 100644 Sources/AmoreCheckout/UI/PreviewFixtures.swift create mode 100644 Sources/AmoreCheckout/UI/View+AmoreCheckout.swift create mode 100644 Tests/AmoreCheckoutTests/CheckoutFlowTests.swift create mode 100644 Tests/AmoreCheckoutTests/CheckoutModelTests.swift create mode 100644 Tests/AmoreCheckoutTests/CheckoutRecoveryTests.swift create mode 100644 Tests/AmoreCheckoutTests/CheckoutResumeTests.swift create mode 100644 Tests/AmoreCheckoutTests/CheckoutSessionClientTests.swift create mode 100644 Tests/AmoreCheckoutTests/Mocks.swift create mode 100644 Tests/AmoreCheckoutTests/PendingCheckoutStoreTests.swift diff --git a/Makefile b/Makefile index 6ff6133..26b51f1 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,7 @@ docs: --transform-for-static-hosting \ --target AmoreLicensing \ --target AmoreStore \ + --target AmoreCheckout \ --output-path ./docs \ --enable-experimental-combined-documentation \ diff --git a/Package.swift b/Package.swift index f7bf263..7f407b9 100644 --- a/Package.swift +++ b/Package.swift @@ -59,6 +59,27 @@ targets.append( ) #endif +#if os(macOS) +products.append( + .library( + name: "AmoreCheckout", + targets: ["AmoreCheckout"] + ) +) +targets.append( + .target( + name: "AmoreCheckout", + dependencies: ["AmoreStore", "AmoreLicensing"] + ) +) +targets.append( + .testTarget( + name: "AmoreCheckoutTests", + dependencies: ["AmoreCheckout", "AmoreLicensing", "AmoreStore"] + ) +) +#endif + let package = Package( name: "AmoreKit", platforms: [ diff --git a/Sources/AmoreCheckout/Checkout/AmoreCheckout+Flow.swift b/Sources/AmoreCheckout/Checkout/AmoreCheckout+Flow.swift new file mode 100644 index 0000000..8f42e24 --- /dev/null +++ b/Sources/AmoreCheckout/Checkout/AmoreCheckout+Flow.swift @@ -0,0 +1,226 @@ +import struct AmoreStore.Product +import Foundation + +extension AmoreCheckout { + + /// Abandons the checkout and returns to ``CheckoutState/idle``: a flow in + /// progress ends with a pending ``start(_:customerEmail:)`` returning + /// `.cancelled`, and a terminal state is cleared so the next start begins + /// fresh. Refused during ``CheckoutState/activating`` + /// (``CheckoutState/isCancellable`` is `false`), so a paid session always + /// runs to a terminal state. + public func cancel() { + if case .activating = state { return } + flow?.cancel() + state = .idle + } + + /// Runs a checkout for `product` to its terminal result: creates a + /// session, polls until payment resolves, and activates the purchased + /// license. + /// + /// ``state`` tracks progress for observers and settles on the matching + /// terminal state before the result is returned, so the two can never + /// disagree. Calling from ``CheckoutState/completed(_:licenseKey:)`` or + /// ``CheckoutState/failed(_:)`` starts a fresh attempt, so a Try Again + /// button is just another `await checkout.start(product)`. Calling while + /// an attempt is already in progress joins it and returns that attempt's + /// result, whichever product it was for, so a surface offering several + /// products should disable its buy buttons on ``isInProgress``. + /// Cancelling the surrounding task, or calling ``cancel()``, ends the + /// flow with `.cancelled`. + /// + /// A purchase interrupted after payment is picked up instead of charged + /// again: a persisted pending session that completed activates its + /// license, and one still processing is polled to its result. Only an + /// unpaid, expired, or stale pending session is superseded by a fresh one. + /// A ``recoverPendingPurchase()`` still in flight is awaited first, and if + /// it activated the interrupted purchase the attempt ends `.cancelled` + /// rather than opening a checkout for a product now owned. + /// - Parameters: + /// - product: The product to purchase, from `AmoreStore.products()`. + /// - customerEmail: Prefills the email field on the checkout page. + @discardableResult + public func start( + _ product: Product, + customerEmail: String? = nil + ) async -> CheckoutResult { + // A re-entrant start (a double-clicked retry, two surfaces driving + // one checkout) joins the attempt in progress instead of reporting a + // false .cancelled for a flow that is still running. + if let flow, isInProgress { + return await flow.value + } + state = .preparing + + let flow = Task { await run(product: product, customerEmail: customerEmail) } + self.flow = flow + let result = await withTaskCancellationHandler { + await flow.value + } onCancel: { + flow.cancel() + } + // A flow ended by cancelling the surrounding task still owns the state + // and returns it to idle; once cancel() or a newer start() has taken + // over, it keeps its hands off. + if case .cancelled = result, self.flow == flow, isInProgress { + state = .idle + } + return result + } + + // MARK: - Private + + private func run(product: Product, customerEmail: String?) async -> CheckoutResult { + if let recovery { + _ = await recovery.value + if case .valid = licensing.status { return .cancelled } + } + if let resumed = await resumePendingSession(for: product) { return resumed } + let session: CheckoutSession + do { + session = try await client.createSession( + productId: product.id, customerEmail: customerEmail + ) + } catch { + guard !Task.isCancelled else { return .cancelled } + return fail(.sessionCreationFailed(underlying: error)) + } + guard !Task.isCancelled else { return .cancelled } + do { + try store.save(PendingCheckout( + session: session, productId: product.id, createdAt: .now + )) + } catch { + store.clear() + return fail(.checkoutNotRecordable) + } + state = .awaitingPayment(session.checkoutURL) + return await poll( + sessionId: session.sessionId, paymentDeadline: session.pollDeadline(from: .now) + ) + } + + // A pending session persisted by an earlier interrupted purchase is + // resolved before any new session is created, so the customer cannot be + // charged twice: a completed session activates its license, one still + // processing is polled to its result, and an open one for the same + // product is reopened at its original URL. Only an unpaid session falls + // through to a fresh checkout: one for another product, an expired or + // stale one, one the server has no record of, or a leftover held by an + // already-licensed user. + private func resumePendingSession(for product: Product) async -> CheckoutResult? { + guard let pending = store.load() else { return nil } + guard pending.createdAt.addingTimeInterval(Self.maxPendingAge) > .now else { + store.clear() + return nil + } + let status: CheckoutSessionStatusResponse + do { + status = try await client.sessionStatus(sessionId: pending.sessionId) + } catch { + guard !Task.isCancelled else { return .cancelled } + if case .httpStatus(404) = error { + store.clear() + return nil + } + return fail(.pendingSessionUnresolved(underlying: error)) + } + guard !Task.isCancelled else { return .cancelled } + switch status.status { + case .complete: + guard let key = status.licenseKey else { + return fail(.pendingSessionUnresolved(underlying: nil)) + } + return await activate(licenseKey: key) + case .processing: + state = .activating + return await poll(sessionId: pending.sessionId, paymentDeadline: nil) + case .expired: + store.clear() + return nil + case .open: + if case .valid = licensing.status { + store.clear() + return nil + } + // The session's own expiry bounds the reopened wait, falling back + // to the pending-age ceiling when the server omitted one. + let expiry = pending.session.pollDeadline(from: pending.createdAt) + guard pending.productId == product.id, expiry > .now else { return nil } + state = .awaitingPayment(pending.session.checkoutURL) + return await poll(sessionId: pending.sessionId, paymentDeadline: expiry) + } + } + + // The wait for payment is bounded by the session's own expiry; the wait + // for the license that follows it is bounded by the number of requests + // instead, so a server that never issues one cannot be polled + // indefinitely. A nil `paymentDeadline` is a session already paid for. + private func poll(sessionId: String, paymentDeadline: Date?) async -> CheckoutResult { + var deadline = paymentDeadline + var activationPolls = 0 + var consecutiveFailures = 0 + while !Task.isCancelled { + if case .activating = state { deadline = nil } + if let deadline { + if deadline <= .now { return fail(.sessionExpired) } + } else { + activationPolls += 1 + if activationPolls > Self.maxActivationPolls { + return fail(.licenseIssuanceTimedOut) + } + } + do { + let status = try await client.sessionStatus(sessionId: sessionId) + guard !Task.isCancelled else { return .cancelled } + consecutiveFailures = 0 + switch status.status { + case .complete: + if let key = status.licenseKey { + return await activate(licenseKey: key) + } + // Payment landed; the license is still being issued. + state = .activating + case .processing: + state = .activating + case .expired: + return fail(.sessionExpired) + case .open: + break + } + } catch { + consecutiveFailures += 1 + if state != .activating { + if case .httpStatus(404) = error { return fail(.sessionExpired) } + if consecutiveFailures > Self.maxPollingFailures { + return fail(.pollingFailed(underlying: error)) + } + } + } + try? await Task.sleep(for: pollInterval * (1 << min(consecutiveFailures, 3))) + } + return .cancelled + } + + private func activate(licenseKey: String) async -> CheckoutResult { + state = .activating + do { + try await licensing.activate(licenseKey: licenseKey) + } catch { + guard !Task.isCancelled else { return .cancelled } + return fail(.activationFailed(licenseKey: licenseKey, underlying: error)) + } + store.clear() + guard case .valid(let license) = licensing.status else { + return fail(.activationFailed(licenseKey: licenseKey, underlying: .invalidToken)) + } + state = .completed(license, licenseKey: licenseKey) + return .completed(license, licenseKey: licenseKey) + } + + private func fail(_ error: CheckoutError) -> CheckoutResult { + state = .failed(error) + return .failed(error) + } +} diff --git a/Sources/AmoreCheckout/Checkout/AmoreCheckout+Navigation.swift b/Sources/AmoreCheckout/Checkout/AmoreCheckout+Navigation.swift new file mode 100644 index 0000000..dff38ca --- /dev/null +++ b/Sources/AmoreCheckout/Checkout/AmoreCheckout+Navigation.swift @@ -0,0 +1,34 @@ +import Foundation + +enum CheckoutNavigationAction: Equatable { + case allow + case interceptSuccess + case interceptCancel +} + +// The server's success and cancel redirect pages are intercepted before they +// render and folded back into the flow. +extension AmoreCheckout { + func navigationAction(for url: URL) -> CheckoutNavigationAction { + if matches(url, pathSuffix: "checkout/success") { return .interceptSuccess } + if matches(url, pathSuffix: "checkout/cancel") { return .interceptCancel } + return .allow + } + + // The polling loop picks up the completed session and activates as usual. + func handleSuccessRedirect() { + guard isInProgress else { return } + state = .activating + } + + func handleCancelRedirect() { + if case .activating = state { return } + cancel() + } + + private func matches(_ url: URL, pathSuffix: String) -> Bool { + guard url.scheme == baseURL.scheme, url.host() == baseURL.host() else { return false } + let expected = baseURL.appending(path: pathSuffix).path() + return url.path() == expected || url.path().hasPrefix(expected + "/") + } +} diff --git a/Sources/AmoreCheckout/Checkout/AmoreCheckout+Recovery.swift b/Sources/AmoreCheckout/Checkout/AmoreCheckout+Recovery.swift new file mode 100644 index 0000000..93d0a42 --- /dev/null +++ b/Sources/AmoreCheckout/Checkout/AmoreCheckout+Recovery.swift @@ -0,0 +1,94 @@ +import AmoreLicensing +import Foundation + +extension AmoreCheckout { + /// Pending sessions older than this are discarded without a network call. + static let maxPendingAge: TimeInterval = 24 * 60 * 60 + + /// How many times a paid session asks for its license before the flow + /// stops waiting. Bounding the requests rather than the elapsed time keeps + /// a server that never issues the license from being polled indefinitely. + static let maxActivationPolls = 100 + + /// How many status checks in a row may fail before a checkout that has not + /// been paid for gives up. A paid one keeps polling to its own bound. + static let maxPollingFailures = 8 + + /// Resolves a checkout interrupted by the app quitting after payment: if + /// the persisted pending session completed, the license is activated + /// silently and returned. Call once at launch. + /// + /// Payment that succeeded but could not be activated on this device is + /// reported through ``state`` as + /// ``CheckoutError/activationFailed(licenseKey:underlying:)``, which + /// carries the license key, rather than resolved silently. + /// + /// A second call made while the first is still running joins it rather + /// than resolving the same pending session twice, and the two entry points + /// wait for each other: ``start(_:customerEmail:)`` waits for a recovery in + /// flight before reading the pending session, and a recovery started during + /// a checkout waits for that checkout, so neither order activates the same + /// key twice. + @discardableResult + public func recoverPendingPurchase() async -> License? { + if let recovery { return await recovery.value } + // A checkout in progress resolves the same pending session before it + // creates anything new, so recovery waits for its result instead of + // reading that session too. + if let flow, isInProgress { + guard case .completed(let license, _) = await flow.value else { return nil } + return license + } + let recovery = Task { await resolvePendingPurchase() } + self.recovery = recovery + let license = await recovery.value + if self.recovery == recovery { self.recovery = nil } + return license + } + + // MARK: - Private + + private func resolvePendingPurchase() async -> License? { + guard let pending = store.load() else { return nil } + guard Date.now.timeIntervalSince(pending.createdAt) < Self.maxPendingAge else { + store.clear() + return nil + } + let status: CheckoutSessionStatusResponse + do { + status = try await client.sessionStatus(sessionId: pending.sessionId) + } catch { + // An unreachable server says nothing about the purchase, so the + // pending session is left for the next launch. + return nil + } + switch status.status { + case .complete: + guard let key = status.licenseKey else { return nil } + return await activateRecovered(licenseKey: key) + case .expired: + store.clear() + case .open: + if case .valid = licensing.status { store.clear() } + case .processing: + break + } + return nil + } + + // A key the server issued that this device cannot activate fails the same + // way at every launch, so the failure is surfaced while the key is still + // recoverable: retrying in silence would end at `maxPendingAge` discarding + // the record, and with it the only copy of a key the customer paid for. + private func activateRecovered(licenseKey: String) async -> License? { + do { + try await licensing.activate(licenseKey: licenseKey) + } catch { + state = .failed(.activationFailed(licenseKey: licenseKey, underlying: error)) + return nil + } + store.clear() + if case .valid(let license) = licensing.status { return license } + return nil + } +} diff --git a/Sources/AmoreCheckout/Checkout/AmoreCheckout.swift b/Sources/AmoreCheckout/Checkout/AmoreCheckout.swift new file mode 100644 index 0000000..6054d19 --- /dev/null +++ b/Sources/AmoreCheckout/Checkout/AmoreCheckout.swift @@ -0,0 +1,103 @@ +import AmoreLicensing +import Foundation +import Observation + +/// Drives Stripe hosted checkout: session creation, status polling, and +/// automatic license activation. +/// +/// Create one instance per app, alongside `AmoreLicensing`, and pass the +/// product to buy to each ``start(_:customerEmail:)`` call. `AmoreCheckout` +/// is UI-independent: observe ``state`` to render any purchase UI, or use the +/// built-in surfaces, which are thin layers over this object: the +/// `amoreCheckout(item:checkout:customerEmail:onResult:)` sheet modifier +/// or an embedded ``AmoreCheckoutView``. +/// +/// Purchases interrupted by the app quitting are recovered with +/// ``recoverPendingPurchase()``, typically at launch. See +/// for full setup and for surfaces of your own, +/// including a browser-only flow with no embedded web view. +@Observable @MainActor +public final class AmoreCheckout { + + /// The current state of the flow. Terminal states persist until the next + /// ``start(_:customerEmail:)``. + public internal(set) var state: CheckoutState = .idle + + let baseURL: URL + let licensing: any LicenseActivating + let client: any CheckoutSessionClient + let store: PendingCheckoutStore + let pollInterval: Duration + @ObservationIgnored + var flow: Task? + @ObservationIgnored + var recovery: Task? + + /// Creates the app's checkout flow. One instance serves every product; + /// pass the product being purchased to ``start(_:customerEmail:)``. + /// - Parameters: + /// - licensing: The licensing instance that activates purchased keys. + /// - bundleIdentifier: The app's bundle identifier. Defaults to `Bundle.main.bundleIdentifier`. + /// - baseURL: The licensing server base URL. + public convenience init( + licensing: AmoreLicensing, + bundleIdentifier: String? = nil, + baseURL: URL = .amoreServer + ) { + self.init( + licensing: licensing as any LicenseActivating, + bundleIdentifier: bundleIdentifier, + baseURL: baseURL + ) + } + + init( + licensing: any LicenseActivating, + bundleIdentifier: String? = nil, + baseURL: URL = .amoreServer, + client: (any CheckoutSessionClient)? = nil, + store: PendingCheckoutStore? = nil, + pollInterval: Duration = .seconds(3) + ) { + let resolved = Self.resolveBundleIdentifier(bundleIdentifier) + self.licensing = licensing + self.baseURL = baseURL + self.client = client ?? HTTPCheckoutSessionClient( + baseURL: baseURL, bundleIdentifier: resolved + ) + self.store = store ?? PendingCheckoutStore(bundleIdentifier: resolved) + self.pollInterval = pollInterval + } + + /// Whether a checkout is running (any non-terminal state except ``CheckoutState/idle``). + public var isInProgress: Bool { + switch state { + case .preparing, .awaitingPayment, .activating: true + case .idle, .completed, .failed: false + } + } + + /// The hosted checkout URL while payment is awaited, `nil` otherwise. + public var checkoutURL: URL? { + if case .awaitingPayment(let url) = state { return url } + return nil + } + + /// The customer portal on the configured server, for manual license management. + public var portalURL: URL { + baseURL.appending(path: "portal") + } + + // MARK: - Internal + + // A missing identifier only shows up later as a confusing server 404, + // so assert here where it can actually be fixed. + private static func resolveBundleIdentifier(_ bundleIdentifier: String?) -> String { + if let bundleIdentifier { return bundleIdentifier } + guard let main = Bundle.main.bundleIdentifier else { + assertionFailure("No bundle identifier available. Pass bundleIdentifier explicitly.") + return "" + } + return main + } +} diff --git a/Sources/AmoreCheckout/Checkout/CheckoutState.swift b/Sources/AmoreCheckout/Checkout/CheckoutState.swift new file mode 100644 index 0000000..245dc06 --- /dev/null +++ b/Sources/AmoreCheckout/Checkout/CheckoutState.swift @@ -0,0 +1,33 @@ +import Foundation +import AmoreLicensing + +/// Where the checkout flow currently stands. +public enum CheckoutState: Sendable, Hashable { + /// No checkout is running. Also the state after a cancellation. + case idle + /// ``AmoreCheckout/start(_:customerEmail:)`` was called; the checkout + /// session is being created. + case preparing + /// The session is open at the given checkout URL and payment is up to + /// the user, whether in an embedded web view or the external browser. + case awaitingPayment(URL) + /// Payment was detected; the license is being issued and activated. + /// The purchase is no longer safely cancellable. + case activating + /// The purchase completed and the license is active on this device. + case completed(License, licenseKey: String) + /// The flow failed. ``CheckoutError/activationFailed(licenseKey:underlying:)`` + /// means payment succeeded; keep showing the license key it carries. + case failed(CheckoutError) +} + +extension CheckoutState { + /// Whether the flow can still be abandoned without losing a payment: + /// `true` until payment is detected, `false` from ``activating`` on. + public var isCancellable: Bool { + switch self { + case .preparing, .awaitingPayment: true + case .idle, .activating, .completed, .failed: false + } + } +} diff --git a/Sources/AmoreCheckout/Checkout/LicenseActivating.swift b/Sources/AmoreCheckout/Checkout/LicenseActivating.swift new file mode 100644 index 0000000..5d411c4 --- /dev/null +++ b/Sources/AmoreCheckout/Checkout/LicenseActivating.swift @@ -0,0 +1,10 @@ +import AmoreLicensing + +/// The slice of `AmoreLicensing` the checkout flow needs, as a seam for tests. +@MainActor +protocol LicenseActivating: AnyObject { + var status: ValidationStatus { get } + func activate(licenseKey: String) async throws(AmoreError) +} + +extension AmoreLicensing: LicenseActivating {} diff --git a/Sources/AmoreCheckout/Client/CheckoutClientError.swift b/Sources/AmoreCheckout/Client/CheckoutClientError.swift new file mode 100644 index 0000000..c062f8f --- /dev/null +++ b/Sources/AmoreCheckout/Client/CheckoutClientError.swift @@ -0,0 +1,22 @@ +import Foundation + +/// A failure talking to the licensing server's checkout endpoints. +public enum CheckoutClientError: LocalizedError, Hashable { + /// The request could not be sent or its payload could not be encoded or decoded. + case network(String) + /// The response was not an HTTP response. + case invalidResponse + /// The server rejected the request because too many were made in a short window. + case rateLimited + /// The server returned an unexpected status code. + case httpStatus(Int) + + public var errorDescription: String? { + switch self { + case .network(let message): message + case .invalidResponse: "Invalid response from server." + case .rateLimited: "Too many requests. Please try again later." + case .httpStatus(let statusCode): "The server returned an unexpected response (\(statusCode))." + } + } +} diff --git a/Sources/AmoreCheckout/Client/CheckoutSessionClient.swift b/Sources/AmoreCheckout/Client/CheckoutSessionClient.swift new file mode 100644 index 0000000..3975baf --- /dev/null +++ b/Sources/AmoreCheckout/Client/CheckoutSessionClient.swift @@ -0,0 +1,86 @@ +import Foundation + +typealias CheckoutTransport = @Sendable (URLRequest) async throws -> (Data, URLResponse) + +/// Creates checkout sessions and reads their status from the licensing server. +protocol CheckoutSessionClient: Sendable { + func createSession(productId: UUID, customerEmail: String?) async throws(CheckoutClientError) -> CheckoutSession + func sessionStatus(sessionId: String) async throws(CheckoutClientError) -> CheckoutSessionStatusResponse +} + +struct HTTPCheckoutSessionClient: CheckoutSessionClient { + private let baseURL: URL + private let bundleIdentifier: String + private let transport: CheckoutTransport + + init( + baseURL: URL, + bundleIdentifier: String, + transport: @escaping CheckoutTransport = { try await URLSession.shared.data(for: $0) } + ) { + self.baseURL = baseURL + self.bundleIdentifier = bundleIdentifier + self.transport = transport + } + + func createSession(productId: UUID, customerEmail: String?) async throws(CheckoutClientError) -> CheckoutSession { + struct Body: Encodable { + let productId: UUID + let customerEmail: String? + } + var request = URLRequest(url: sessionsURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + do { + request.httpBody = try JSONEncoder().encode(Body(productId: productId, customerEmail: customerEmail)) + } catch { + throw .network("Could not encode request: \(error.localizedDescription)") + } + return try await perform(request) + } + + func sessionStatus(sessionId: String) async throws(CheckoutClientError) -> CheckoutSessionStatusResponse { + var request = URLRequest(url: sessionsURL.appendingPathComponent(sessionId)) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + return try await perform(request) + } + + // MARK: - Private + + private var sessionsURL: URL { + baseURL + .appendingPathComponent("v1/public/apps") + .appendingPathComponent(bundleIdentifier) + .appendingPathComponent("checkout/sessions") + } + + private func perform(_ request: URLRequest) async throws(CheckoutClientError) -> Response { + let data: Data + let response: URLResponse + do { + (data, response) = try await transport(request) + } catch { + throw .network(error.localizedDescription) + } + guard let http = response as? HTTPURLResponse else { + throw .invalidResponse + } + switch http.statusCode { + case 200...299: + break + case 429: + throw .rateLimited + default: + throw .httpStatus(http.statusCode) + } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + do { + return try decoder.decode(Response.self, from: data) + } catch { + throw .network("Could not decode response: \(error.localizedDescription)") + } + } +} diff --git a/Sources/AmoreCheckout/Documentation.docc/Custom Checkout UI.md b/Sources/AmoreCheckout/Documentation.docc/Custom Checkout UI.md new file mode 100644 index 0000000..eec1981 --- /dev/null +++ b/Sources/AmoreCheckout/Documentation.docc/Custom Checkout UI.md @@ -0,0 +1,88 @@ +# Custom Checkout UI + +Replace the success screen, embed checkout in your own container, or run it in the external browser. + +## Overview + +The sheet modifier is a thin layer over ``AmoreCheckout``, which is UI-independent. Every surface below drives the same object and activates the purchased license the same way. + +## Custom Success Screens + +The sheet ends with a receipt-style success screen summarizing the activated license. Replace it with a screen of your own, built from the same ``CheckoutCompletion``, and call its ``CheckoutCompletion/onDone`` to close the sheet. + +```swift +.amoreCheckout(item: $buying, checkout: checkout, completedView: { completion in + VStack { + Text("Welcome to \(completion.product.name)!") + Button("Start") { completion.onDone?() } + } +}) +``` + +> Note: `onDone` is `nil` in a container that closes itself, so a custom screen omits its button just as the default receipt does. + +## Custom Containers + +Embed ``AmoreCheckoutView`` in a container of your own and close it from `onDone`, which the success screen's button calls. + +```swift +AmoreCheckoutView( + checkout: checkout, + product: product, + onDone: { isPresented = false } +) +``` + +`onDone` covers both ways checkout ends for good: the success screen's button calls it, and so does abandoning checkout through the page's cancel link. Pass `onResult` as well to act on the outcome itself. + +``AmoreCheckoutView`` takes the same `completedView` builder as the sheet modifier. + +> Important: The view's lifetime drives the flow. Removing it from the hierarchy cancels a checkout in progress. For a flow that outlives its UI, drive ``AmoreCheckout`` directly. + +## Browser-Only Checkout + +Skip the embedded web view entirely and hand the URL to the browser as soon as the session opens. + +```swift +Button("Buy") { + Task { + switch await checkout.start(product) { + case .completed(let license, _): + print("Unlocked \(license.product.name)") + case .cancelled: + break + case .failed(let error): + print(error.localizedDescription) + } + } +} +.disabled(checkout.isInProgress) +.onChange(of: checkout.checkoutURL) { _, url in + if let url { NSWorkspace.shared.open(url) } +} +``` + +``AmoreCheckout/isInProgress`` is observed, so the button comes back by itself once the attempt reaches a terminal state, and a retry is another tap. + +> Important: A surface offering several products needs that guard. ``AmoreCheckout/start(_:customerEmail:)`` joins an attempt already running rather than starting a second one, and returns that attempt's result whichever product it was for. The sheet modifier presents one product at a time and needs nothing. + +## Observing State + +``AmoreCheckout`` is `@Observable`, so a paywall built from no supplied views at all reads ``AmoreCheckout/state`` directly. + +```swift +switch checkout.state { +case .idle, .preparing: + ProgressView() +case .awaitingPayment(let url): + Link("Pay", destination: url) +case .activating: + Text("Activating…") +case .completed(let license, _): + Text("Unlocked \(license.product.name)") +case .failed(let error): + Text(error.localizedDescription) +} +``` + +``CheckoutState`` settles on the matching terminal case before ``AmoreCheckout/start(_:customerEmail:)`` returns, so the two can never disagree. Use ``CheckoutState/isCancellable`` to hide a cancel button once payment is detected. diff --git a/Sources/AmoreCheckout/Documentation.docc/Getting Started.md b/Sources/AmoreCheckout/Documentation.docc/Getting Started.md new file mode 100644 index 0000000..f026b95 --- /dev/null +++ b/Sources/AmoreCheckout/Documentation.docc/Getting Started.md @@ -0,0 +1,103 @@ +# Getting Started + +This article describes how to get started with AmoreCheckout. + +## Installation + +In Xcode, go to **File → Add Package Dependencies…** and enter: + +``` +https://github.com/AmoreComputer/AmoreKit +``` + +Or add it to your `Package.swift`: + +```swift +.package(url: "https://github.com/AmoreComputer/AmoreKit", from: "0.1") +``` + +## Requirements + +Checkout runs in an embedded web view, so sandboxed apps need the outgoing-connections entitlement: + +```xml +com.apple.security.network.client + +``` + +> Note: Payment can always finish in the external browser, which Apple Pay requires. The app activates the license either way. + +## AmoreCheckout + +To get started with AmoreCheckout, create an instance of ``AmoreCheckout`` with the `AmoreLicensing` instance that activates purchased keys. + +```swift +let licensing = try AmoreLicensing( + publicKey: "sa92JNtsaYefYp0MIWQbKu1hpS9bSN89ta7b8mlPbI8=", +) +let checkout = AmoreCheckout(licensing: licensing) +``` + +You own the ``AmoreCheckout`` object: create it once where you create `AmoreLicensing`, and pass it down. One instance serves every product, and its lifetime is yours, so nothing is re-created behind your back on view updates. + +## Presenting Checkout + +Fetch products with `AmoreStore`, then set the product to buy. The ``SwiftUICore/View/amoreCheckout(item:checkout:customerEmail:onResult:)`` modifier presents checkout for whatever the binding holds. + +```swift +import AmoreCheckout +import SwiftUI +import struct AmoreStore.Product + +struct PaywallView: View { + let checkout: AmoreCheckout + let products: [Product] + @State private var buying: Product? + + var body: some View { + VStack { + ForEach(products) { product in + Button("Buy \(product.name)") { buying = product } + } + } + .amoreCheckout(item: $buying, checkout: checkout) + } +} +``` + +> Note: `AmoreLicensing` and `AmoreStore` both declare a type named `Product`, so importing both modules whole leaves the name ambiguous. Import the one checkout takes directly, as above. + +When payment completes, the license is activated on this device and `licensing.status` flips to `.valid`, so gated UI unlocks reactively. + +## Handling Results + +Pass `onResult` to receive the terminal ``CheckoutResult`` of every attempt, including retries after a failure. + +```swift +.amoreCheckout(item: $buying, checkout: checkout) { result in + switch result { + case .completed(let license, let licenseKey): + print("Unlocked \(license.product.name) with \(licenseKey)") + case .cancelled: + break + case .failed(.activationFailed(let licenseKey, _)): + print("Activate manually with \(licenseKey)") + case .failed(let error): + print(error.localizedDescription) + } +} +``` + +> Tip: The sheet already shows the license key on success, and on ``CheckoutError/activationFailed(licenseKey:underlying:)`` it shows the key with a copy button. The result carries it for your own UI too, and the key is never persisted beyond the flow. + +## Recovering Interrupted Purchases + +If the app quits mid-checkout after the user paid, resolve the purchase once at launch: + +```swift +await checkout.recoverPendingPurchase() +``` + +``AmoreCheckout/start(_:customerEmail:)`` guards the same interrupted purchase, so a customer cannot be charged twice by buying again before recovery runs: a session that already completed activates its license, one still processing is polled to its result, and an open session for the same product reopens where it left off. + +> Important: When that session cannot be checked at all, the flow fails with ``CheckoutError/pendingSessionUnresolved(underlying:)`` rather than charging again. Retrying is safe and re-checks the session. diff --git a/Sources/AmoreCheckout/Documentation.docc/Index.md b/Sources/AmoreCheckout/Documentation.docc/Index.md new file mode 100644 index 0000000..1b4b0c9 --- /dev/null +++ b/Sources/AmoreCheckout/Documentation.docc/Index.md @@ -0,0 +1,39 @@ +# ``AmoreCheckout`` + +Embedded Stripe checkout with automatic license activation for macOS apps. + +## Overview + +AmoreCheckout is the checkout SDK for [Amore](https://amore.computer). + +AmoreCheckout turns buying a license into three steps with zero context switches: click buy, pay in an in-app sheet, and the app unlocks itself. It bridges `AmoreStore` (products) and `AmoreLicensing` (activation): checkout completes, the license key is fetched from the server, and `AmoreLicensing.activate(licenseKey:)` runs automatically. + +The core is the UI-independent ``AmoreCheckout`` object. Observe its ``AmoreCheckout/state`` to build a custom paywall, or run checkout entirely through the external browser. The sheet modifier and ``AmoreCheckoutView`` are thin layers over it. + +## Topics + +### Articles + +- +- + +### Essentials + +- ``AmoreCheckout`` +- ``SwiftUICore/View/amoreCheckout(item:checkout:customerEmail:onResult:)`` +- ``SwiftUICore/View/amoreCheckout(item:checkout:customerEmail:completedView:onResult:)`` +- ``CheckoutResult`` + +### Customization + +- ``AmoreCheckoutView`` +- ``CheckoutCompletion`` + +### Observing the Flow + +- ``CheckoutState`` + +### Errors + +- ``CheckoutError`` +- ``CheckoutClientError`` diff --git a/Sources/AmoreCheckout/Documentation.docc/images/heart.svg b/Sources/AmoreCheckout/Documentation.docc/images/heart.svg new file mode 100644 index 0000000..4a7d72e --- /dev/null +++ b/Sources/AmoreCheckout/Documentation.docc/images/heart.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Sources/AmoreCheckout/Documentation.docc/theme-settings.json b/Sources/AmoreCheckout/Documentation.docc/theme-settings.json new file mode 100644 index 0000000..f974879 --- /dev/null +++ b/Sources/AmoreCheckout/Documentation.docc/theme-settings.json @@ -0,0 +1,35 @@ +{ + "theme": { + "aside": { + "border-radius": "6px", + "border-style": "solid", + "border-width": "2px" + }, + "border-radius": "0", + "button": { + "border-radius": "16px", + "border-width": "1px", + "border-style": "solid" + }, + "code": { + "border-radius": "16px", + "border-width": "1px", + "border-style": "solid" + }, + "color": { + "amore": "#f09000", + "documentation-intro-fill": { "dark": "#000", "light": "#fff" }, + "documentation-intro-accent": "var(--color-jwtkit)", + "logo-base": { "dark": "#fff", "light": "#000" }, + "logo-shape": { "dark": "#000", "light": "#fff" }, + "link": { "dark": "var(--color-amore)", "light": "var(--color-amore)" }, + "fill": { "dark": "#222", "light": "#eee" }, + "button-background": "var(--color-amore)", + "fill-blue": "var(--color-amore)", + "figure-blue": "var(--color-amore)" + }, + "icons": { + "technology": "/images/AmoreCheckout/heart.svg" + } + } +} diff --git a/Sources/AmoreCheckout/Models/CheckoutCompletion.swift b/Sources/AmoreCheckout/Models/CheckoutCompletion.swift new file mode 100644 index 0000000..15bf3b8 --- /dev/null +++ b/Sources/AmoreCheckout/Models/CheckoutCompletion.swift @@ -0,0 +1,35 @@ +import AmoreLicensing +import struct AmoreStore.Product + +/// The details of a completed purchase, handed to the success screen. +/// +/// ``AmoreCheckoutView`` builds one when the flow reaches +/// ``CheckoutState/completed(_:licenseKey:)`` and passes it to its +/// `completedView` builder. The sheet's built-in success screen renders it +/// as a receipt; a custom screen reads the same details. +public struct CheckoutCompletion { + /// The license activated on this device. + public let license: License + /// The purchased license key, never persisted beyond the flow. + public let licenseKey: String + /// Closes the container the success screen is shown in. The checkout sheet + /// always supplies one; an ``AmoreCheckoutView`` supplies one only when its + /// own `onDone` was passed, so a screen shown there offers its Done button + /// conditionally, just as the default receipt does. + public let onDone: (() -> Void)? + /// The purchased product. + public let product: Product + + /// Creates a completion, for previewing and testing a custom success screen. + public init( + license: License, + licenseKey: String, + onDone: (() -> Void)? = nil, + product: Product + ) { + self.license = license + self.licenseKey = licenseKey + self.onDone = onDone + self.product = product + } +} diff --git a/Sources/AmoreCheckout/Models/CheckoutError.swift b/Sources/AmoreCheckout/Models/CheckoutError.swift new file mode 100644 index 0000000..c05e7b9 --- /dev/null +++ b/Sources/AmoreCheckout/Models/CheckoutError.swift @@ -0,0 +1,56 @@ +import Foundation +import AmoreLicensing + +/// Errors surfaced by the embedded checkout flow. +public enum CheckoutError: Error, Sendable, Hashable { + /// The checkout session could not be created. + case sessionCreationFailed(underlying: CheckoutClientError) + /// The session was created but could not be recorded on this device, so a + /// purchase interrupted by the app quitting could not be recovered. The + /// session is abandoned before payment rather than charging for a checkout + /// that cannot be resumed. + case checkoutNotRecordable + /// A purchase interrupted earlier could not be resolved, so no new session + /// was created: charging again risks paying twice for the same license. + /// `underlying` is the status check's failure, or `nil` when the server + /// reported a paid session without the license key it should carry. + /// Retrying re-checks that session and picks it up if it was paid. + case pendingSessionUnresolved(underlying: CheckoutClientError?) + /// The checkout session expired, or the server no longer has any record + /// of it, before payment completed. + case sessionExpired + /// The server could not be reached for long enough that the flow stopped + /// waiting for payment. The session is untouched: retrying re-checks it + /// and picks it up if it was paid in a browser tab after all. + case pollingFailed(underlying: CheckoutClientError) + /// Payment succeeded but the server did not issue the license before the + /// flow stopped waiting for it. The purchase is not lost: the pending + /// session outlives this failure, so a retry or the next launch's + /// ``AmoreCheckout/recoverPendingPurchase()`` picks the license up. + case licenseIssuanceTimedOut + /// Payment succeeded but the license could not be activated on this device. + case activationFailed(licenseKey: String, underlying: AmoreError) +} + +extension CheckoutError: LocalizedError { + public var errorDescription: String? { + switch self { + case .sessionCreationFailed(let underlying): + underlying.errorDescription + case .checkoutNotRecordable: + "Checkout could not start because this purchase could not be saved on your Mac. You have not been charged." + // The transport detail matters less than the fact that an earlier + // purchase is unconfirmed and retrying is safe. + case .pendingSessionUnresolved: + "Your previous purchase could not be confirmed. Please try again." + case .sessionExpired: + "This checkout session has expired." + case .pollingFailed: + "Checkout lost contact with the server. If you completed a payment, it will be picked up the next time you open the app." + case .licenseIssuanceTimedOut: + "Your payment went through, but your license has not arrived yet. It will finish activating shortly, or the next time you open the app." + case .activationFailed(_, let underlying): + underlying.errorDescription + } + } +} diff --git a/Sources/AmoreCheckout/Models/CheckoutResult.swift b/Sources/AmoreCheckout/Models/CheckoutResult.swift new file mode 100644 index 0000000..1f921c8 --- /dev/null +++ b/Sources/AmoreCheckout/Models/CheckoutResult.swift @@ -0,0 +1,14 @@ +import AmoreLicensing +import Foundation + +/// The outcome of an embedded checkout flow. +public enum CheckoutResult: Sendable, Hashable { + /// The purchase completed and the license was activated on this device. + case completed(License, licenseKey: String) + /// The user dismissed checkout without completing a purchase. + case cancelled + /// The flow failed. `CheckoutError.activationFailed` carries the license + /// key so it can always be shown to the user: the purchase succeeded even + /// though automatic activation did not. + case failed(CheckoutError) +} diff --git a/Sources/AmoreCheckout/Models/CheckoutSession.swift b/Sources/AmoreCheckout/Models/CheckoutSession.swift new file mode 100644 index 0000000..851a32a --- /dev/null +++ b/Sources/AmoreCheckout/Models/CheckoutSession.swift @@ -0,0 +1,17 @@ +import Foundation + +/// A Stripe Checkout Session created by the licensing server. +struct CheckoutSession: Codable, Equatable, Sendable { + var sessionId: String + var checkoutURL: URL + var expiresAt: Date? +} + +extension CheckoutSession { + /// The moment polling gives up on the session. Stripe sessions live at + /// most 24 hours, so when the server omits `expiresAt` that ceiling + /// bounds the poll instead. + func pollDeadline(from start: Date) -> Date { + expiresAt ?? start.addingTimeInterval(24 * 60 * 60) + } +} diff --git a/Sources/AmoreCheckout/Models/CheckoutSessionStatus.swift b/Sources/AmoreCheckout/Models/CheckoutSessionStatus.swift new file mode 100644 index 0000000..99774ff --- /dev/null +++ b/Sources/AmoreCheckout/Models/CheckoutSessionStatus.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Where a checkout session stands, as reported by the licensing server. +enum CheckoutSessionStatus: String, Codable, Sendable { + case open + case processing + case complete + case expired +} + +/// Status poll response; `licenseKey` is present only once a license was issued. +struct CheckoutSessionStatusResponse: Codable, Equatable, Sendable { + var status: CheckoutSessionStatus + var licenseKey: String? +} diff --git a/Sources/AmoreCheckout/Persistence/PendingCheckoutStore.swift b/Sources/AmoreCheckout/Persistence/PendingCheckoutStore.swift new file mode 100644 index 0000000..d099a0c --- /dev/null +++ b/Sources/AmoreCheckout/Persistence/PendingCheckoutStore.swift @@ -0,0 +1,51 @@ +import Foundation + +/// A checkout session that was started but not yet resolved, persisted so a +/// purchase survives the app quitting mid-checkout. Embeds the full server +/// session so an interrupted checkout can be reopened at its original URL +/// and judged against its real expiry. +struct PendingCheckout: Codable, Equatable, Sendable { + var session: CheckoutSession + var productId: UUID + var createdAt: Date + + var sessionId: String { session.sessionId } +} + +/// Persists the pending checkout as a JSON file in Application Support, using +/// the same per-bundle-identifier directory scheme as `FileTokenStore`. +struct PendingCheckoutStore: Sendable { + static let fileName = "pending-checkout.json" + + private let fileURL: URL + + init(bundleIdentifier: String) { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + self.fileURL = appSupport + .appendingPathComponent(bundleIdentifier) + .appendingPathComponent(Self.fileName) + } + + init(directory: URL) { + self.fileURL = directory.appendingPathComponent(Self.fileName) + } + + func save(_ pending: PendingCheckout) throws { + let directory = fileURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + try encoder.encode(pending).write(to: fileURL, options: .atomic) + } + + func load() -> PendingCheckout? { + guard let data = try? Data(contentsOf: fileURL) else { return nil } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try? decoder.decode(PendingCheckout.self, from: data) + } + + func clear() { + try? FileManager.default.removeItem(at: fileURL) + } +} diff --git a/Sources/AmoreCheckout/UI/AmoreCheckoutView.swift b/Sources/AmoreCheckout/UI/AmoreCheckoutView.swift new file mode 100644 index 0000000..bc6c8fa --- /dev/null +++ b/Sources/AmoreCheckout/UI/AmoreCheckoutView.swift @@ -0,0 +1,195 @@ +import AmoreLicensing +import struct AmoreStore.Product +import SwiftUI + +/// The checkout flow's UI: state-driven content around the embedded web view. +/// +/// A thin rendering layer over ``AmoreCheckout``, embeddable in any container: +/// a popover, a custom window, a settings pane. The view starts the flow when +/// it appears idle, renders every ``AmoreCheckout/state`` including the +/// retry and license-key rescue UI on failure, and reports each attempt's +/// terminal result through `onResult`. Appearing while the flow holds a +/// terminal state renders that state rather than buying again; arm a new +/// purchase with ``AmoreCheckout/cancel()``. +/// +/// The screen shown after a completed purchase is built in by default; pass +/// a `completedView` builder to replace it with a custom screen driven by +/// the same ``CheckoutCompletion``. +/// +/// For the standard sheet presentation, use the +/// `amoreCheckout(item:checkout:customerEmail:onResult:)` view modifier +/// instead. +/// +/// > Important: The view's lifetime drives the flow: removing it from the +/// > hierarchy cancels a checkout in progress. For a flow that outlives its +/// > UI, drive ``AmoreCheckout`` directly. +public struct AmoreCheckoutView: View { + private let checkout: AmoreCheckout + // Builds the screen shown once the purchase completes. `nil` renders the + // built-in one, which is what `Completed == EmptyView` stands for. + private let completedView: ((CheckoutCompletion) -> Completed)? + private let customerEmail: String? + private let onDone: (() -> Void)? + private let onResult: ((CheckoutResult) -> Void)? + private let product: Product + // Shows a waiting indicator instead of the web view while checkout + // continues in the external browser. + private let waitingForBrowser: Bool + + // The checkout page failed to load. The session is untouched and still + // payable, so this is offered as a reload rather than a checkout failure. + @State private var pageFailure: Error? + // Counts purchase attempts, so each retry is a new pass of the same + // view-owned task rather than work that outlives the view. + @State private var attempt = 0 + + /// Creates the checkout UI for one purchase attempt, with a custom + /// success screen. + /// - Parameters: + /// - checkout: The flow to start and render. Inject it idle; the view + /// starts it on appearance. + /// - product: The product to purchase, from `AmoreStore.products()`. + /// - customerEmail: Prefills the email field on the checkout page. + /// - completedView: Builds the success screen from the + /// ``CheckoutCompletion`` once the purchase completes. + /// - onDone: Called when the container should close: the user finished + /// with the success screen, or abandoned checkout. Dismiss the + /// container from here. Omitted, the success screen receives no Done + /// action, for a container that closes itself. + /// - onResult: Called with the terminal ``CheckoutResult`` of every + /// attempt, including retries after a failure. + public init( + checkout: AmoreCheckout, + product: Product, + customerEmail: String? = nil, + @ViewBuilder completedView: @escaping (CheckoutCompletion) -> Completed, + onDone: (() -> Void)? = nil, + onResult: ((CheckoutResult) -> Void)? = nil + ) { + self.init( + checkout: checkout, + product: product, + customerEmail: customerEmail, + completedView: completedView, + waitingForBrowser: false, + onDone: onDone, + onResult: onResult + ) + } + + init( + checkout: AmoreCheckout, + product: Product, + customerEmail: String?, + completedView: ((CheckoutCompletion) -> Completed)?, + waitingForBrowser: Bool, + onDone: (() -> Void)?, + onResult: ((CheckoutResult) -> Void)? + ) { + self.checkout = checkout + self.completedView = completedView + self.customerEmail = customerEmail + self.onDone = onDone + self.onResult = onResult + self.product = product + self.waitingForBrowser = waitingForBrowser + } + + public var body: some View { + content + .task(id: attempt) { + guard case .idle = checkout.state else { return } + await run() + } + } + + // MARK: - Private + + private func run() async { + pageFailure = nil + let result = await checkout.start(product, customerEmail: customerEmail) + onResult?(result) + if case .cancelled = result, !Task.isCancelled { onDone?() } + } + + @ViewBuilder + private func completedContent(license: License, licenseKey: String) -> some View { + let completion = CheckoutCompletion( + license: license, + licenseKey: licenseKey, + onDone: onDone, + product: product + ) + if let completedView { + completedView(completion) + } else { + CheckoutCompletedView(completion: completion) + } + } + + @ViewBuilder + private var content: some View { + switch checkout.state { + case .idle, .preparing: + ProgressView("Preparing checkout…") + case .awaitingPayment(let url): + if waitingForBrowser { + browserWait + } else if let pageFailure { + CheckoutPageFailureView(error: pageFailure) { self.pageFailure = nil } + } else { + CheckoutWebView(url: url, checkout: checkout) { pageFailure = $0 } + } + case .activating: + ProgressView("Activating…") + case .completed(let license, let licenseKey): + completedContent(license: license, licenseKey: licenseKey) + case .failed(let error): + CheckoutFailureView(error: error, portalURL: checkout.portalURL) { + guard case .failed = checkout.state else { return } + checkout.cancel() + attempt += 1 + } + } + } + + private var browserWait: some View { + VStack(spacing: 12) { + ProgressView() + Text("Complete your purchase in the browser…") + } + } +} + +extension AmoreCheckoutView where Completed == EmptyView { + /// Creates the checkout UI for one purchase attempt, with the built-in + /// success screen. + /// - Parameters: + /// - checkout: The flow to start and render. Inject it idle; the view + /// starts it on appearance. + /// - product: The product to purchase, from `AmoreStore.products()`. + /// - customerEmail: Prefills the email field on the checkout page. + /// - onDone: Called when the container should close: the user finished + /// with the success screen, or abandoned checkout. Dismiss the + /// container from here. Omitted, the success screen renders without a + /// Done button, for a container that closes itself. + /// - onResult: Called with the terminal ``CheckoutResult`` of every + /// attempt, including retries after a failure. + public init( + checkout: AmoreCheckout, + product: Product, + customerEmail: String? = nil, + onDone: (() -> Void)? = nil, + onResult: ((CheckoutResult) -> Void)? = nil + ) { + self.init( + checkout: checkout, + product: product, + customerEmail: customerEmail, + completedView: nil, + waitingForBrowser: false, + onDone: onDone, + onResult: onResult + ) + } +} diff --git a/Sources/AmoreCheckout/UI/CheckoutCompletedView.swift b/Sources/AmoreCheckout/UI/CheckoutCompletedView.swift new file mode 100644 index 0000000..6220870 --- /dev/null +++ b/Sources/AmoreCheckout/UI/CheckoutCompletedView.swift @@ -0,0 +1,127 @@ +import AmoreLicensing +import struct AmoreStore.Product +import SwiftUI + +struct CheckoutCompletedView: View { + private let completion: CheckoutCompletion + + // Triggers the checkmark's single entrance bounce. + @State private var hasAppeared = false + + init(completion: CheckoutCompletion) { + self.completion = completion + } + + var body: some View { + VStack(spacing: 24) { + Spacer() + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 56)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.green) + .symbolEffect(.bounce, value: hasAppeared) + VStack(spacing: 6) { + Text("Purchase complete") + .font(.title2.bold()) + Text("Thank you for your purchase. Your license is now activated.") + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + summary + Spacer() + if let onDone = completion.onDone { + Button("Done", action: onDone) + .buttonStyle(.borderedProminent) + .controlSize(.large) + .keyboardShortcut(.defaultAction) + } + } + .padding(32) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { hasAppeared = true } + } + + // MARK: - Private + + private var summary: some View { + VStack(spacing: 16) { + VStack(spacing: 4) { + HStack { + Text(completion.product.name) + .font(.headline) + if let price = completion.product.displayPrice { + Spacer() + Text(price) + .font(.headline) + .foregroundStyle(.secondary) + } + } + if let email = completion.license.customer?.email { + detailRow("Licensed to", email) + } + if let validity { + detailRow(validity.label, validity.value) + } + } + Divider() + VStack(alignment: .leading, spacing: 4) { + Text("License key") + .font(.callout) + .foregroundStyle(.secondary) + LicenseKeyView(licenseKey: completion.licenseKey) + Text("Keep it safe to activate other devices.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(16) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 12)) + } + + // The single line that best describes how long the purchase lasts: + // the renewal date for a live subscription, otherwise the expiry. + private var validity: (label: String, value: String)? { + if case .renewing(let renewsAt) = completion.license.subscriptionState { + return ("Renews", renewsAt.formatted(date: .abbreviated, time: .omitted)) + } + if let expiresAt = completion.license.expiresAt { + return ("Valid until", expiresAt.formatted(date: .abbreviated, time: .omitted)) + } + return nil + } + + private func detailRow(_ label: String, _ value: String) -> some View { + HStack { + Text(label) + .foregroundStyle(.secondary) + Spacer() + Text(value) + } + .font(.callout) + } +} + +#if DEBUG +#Preview("With Done") { + CheckoutCompletedView( + completion: CheckoutCompletion( + license: .preview, + licenseKey: "AMORE-4F2A-91C7-BD03", + onDone: {}, + product: .preview + ) + ) + .frame(width: 480, height: 640) +} + +#Preview("Self-closing Container") { + CheckoutCompletedView( + completion: CheckoutCompletion( + license: .preview, + licenseKey: "AMORE-4F2A-91C7-BD03", + product: .preview + ) + ) + .frame(width: 480, height: 640) +} +#endif diff --git a/Sources/AmoreCheckout/UI/CheckoutFailureView.swift b/Sources/AmoreCheckout/UI/CheckoutFailureView.swift new file mode 100644 index 0000000..66a866c --- /dev/null +++ b/Sources/AmoreCheckout/UI/CheckoutFailureView.swift @@ -0,0 +1,119 @@ +import AmoreLicensing +import SwiftUI + +/// The terminal error screen for a failed checkout. +/// +/// Activation failures surface the license key so the completed purchase is +/// never lost, and a license still on its way reads as a warning rather than +/// a failure. Every case offers a retry: resuming a paid pending session +/// re-attempts activation instead of charging again. +struct CheckoutFailureView: View { + let error: CheckoutError + let portalURL: URL + let retry: () -> Void + + // Triggers the symbol's single entrance bounce. + @State private var hasAppeared = false + + var body: some View { + VStack(spacing: 24) { + Spacer() + Image(systemName: presentation.symbol) + .font(.system(size: 56)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(presentation.color) + .symbolEffect(.bounce, value: hasAppeared) + VStack(spacing: 6) { + Text(presentation.title) + .font(.title2.bold()) + Text(error.localizedDescription) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + if let licenseKey { + keyCard(licenseKey) + } + Spacer() + Button("Try Again", action: retry) + .buttonStyle(.borderedProminent) + .controlSize(.large) + .keyboardShortcut(.defaultAction) + } + .padding(32) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { hasAppeared = true } + } + + // MARK: - Private + + // A purchase that succeeded but did not activate is a warning, not a + // failure; only an actual loss gets the red cross. + private var presentation: (symbol: String, color: Color, title: String) { + switch error { + case .activationFailed: + ("exclamationmark.triangle.fill", .yellow, "Activation failed") + case .licenseIssuanceTimedOut: + ("clock.fill", .yellow, "License on its way") + case .pollingFailed: + ("exclamationmark.triangle.fill", .yellow, "Connection lost") + default: + ("xmark.circle.fill", .red, "Purchase failed") + } + } + + private var licenseKey: String? { + guard case .activationFailed(let key, _) = error else { return nil } + return key + } + + private func keyCard(_ key: String) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text("Activate manually with this license key:") + .font(.callout) + .foregroundStyle(.secondary) + LicenseKeyView(licenseKey: key) + Divider() + HStack { + Text("Manage your purchase") + .foregroundStyle(.secondary) + Spacer() + Link("Customer Portal", destination: portalURL) + } + .font(.callout) + } + .padding(16) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 12)) + } +} + +#if DEBUG +#Preview("Activation Failed") { + CheckoutFailureView( + error: .activationFailed( + licenseKey: "AMORE-4F2A-91C7-BD03", + underlying: .network(.requestFailed("The request timed out.")) + ), + portalURL: URL(string: "https://api.amore.computer/portal")!, + retry: {} + ) + .frame(width: 480, height: 640) +} + +#Preview("License Delayed") { + CheckoutFailureView( + error: .licenseIssuanceTimedOut, + portalURL: URL(string: "https://api.amore.computer/portal")!, + retry: {} + ) + .frame(width: 480, height: 640) +} + +#Preview("Session Expired") { + CheckoutFailureView( + error: .sessionExpired, + portalURL: URL(string: "https://api.amore.computer/portal")!, + retry: {} + ) + .frame(width: 480, height: 640) +} +#endif diff --git a/Sources/AmoreCheckout/UI/CheckoutPageFailureView.swift b/Sources/AmoreCheckout/UI/CheckoutPageFailureView.swift new file mode 100644 index 0000000..c5f1d57 --- /dev/null +++ b/Sources/AmoreCheckout/UI/CheckoutPageFailureView.swift @@ -0,0 +1,50 @@ +import SwiftUI + +/// The reload prompt shown when the checkout page itself fails to load. +/// +/// A page that never loaded is not a failed checkout: the session stays open +/// and payable, in the browser as much as here, so dismissing this returns to +/// a fresh load of the same page rather than starting a new attempt. +struct CheckoutPageFailureView: View { + let error: any Error + let reload: () -> Void + + // Triggers the symbol's single entrance bounce. + @State private var hasAppeared = false + + var body: some View { + VStack(spacing: 24) { + Spacer() + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 56)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.orange) + .symbolEffect(.bounce, value: hasAppeared) + VStack(spacing: 6) { + Text("Couldn't load checkout") + .font(.title2.bold()) + Text(error.localizedDescription) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + Spacer() + Button("Try Again", action: reload) + .buttonStyle(.borderedProminent) + .controlSize(.large) + .keyboardShortcut(.defaultAction) + } + .padding(32) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { hasAppeared = true } + } +} + +#if DEBUG +#Preview { + CheckoutPageFailureView( + error: URLError(.notConnectedToInternet), + reload: {} + ) + .frame(width: 480, height: 640) +} +#endif diff --git a/Sources/AmoreCheckout/UI/CheckoutSheet.swift b/Sources/AmoreCheckout/UI/CheckoutSheet.swift new file mode 100644 index 0000000..35d823a --- /dev/null +++ b/Sources/AmoreCheckout/UI/CheckoutSheet.swift @@ -0,0 +1,72 @@ +import AppKit +import struct AmoreStore.Product +import SwiftUI + +/// Close and open-in-browser chrome around ``AmoreCheckoutView``. +struct CheckoutSheet: View { + let checkout: AmoreCheckout + let completedView: ((CheckoutCompletion) -> Completed)? + let customerEmail: String? + let onResult: (CheckoutResult) -> Void + let product: Product + + @Environment(\.dismiss) private var dismiss + + // Set on handoff to the external browser + // A new checkout URL supersedes the handoff. + @State private var isWaitingForBrowser = false + + // Closing during activation needs a second step: the user has paid, and + // an interrupted activation only finishes at the next launch. + @State private var isCloseConfirmationPresented = false + + var body: some View { + VStack(spacing: 0) { + header + Divider() + AmoreCheckoutView( + checkout: checkout, + product: product, + customerEmail: customerEmail, + completedView: completedView, + waitingForBrowser: isWaitingForBrowser, + onDone: { dismiss() }, + onResult: onResult + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(width: 480, height: 640) + .onChange(of: checkout.checkoutURL) { + isWaitingForBrowser = false + } + } + + private var header: some View { + HStack { + Button("Close") { + if checkout.state.isCancellable || !checkout.isInProgress { + dismiss() + } else { + isCloseConfirmationPresented = true + } + } + .keyboardShortcut(.cancelAction) + Spacer() + if let url = checkout.checkoutURL, !isWaitingForBrowser { + Button("Open in Browser") { + NSWorkspace.shared.open(url) + isWaitingForBrowser = true + } + } + } + .padding(12) + .confirmationDialog( + "Finish activating later?", + isPresented: $isCloseConfirmationPresented + ) { + Button("Close") { dismiss() } + } message: { + Text("Your payment went through. If you close now, your license will finish activating the next time you open the app.") + } + } +} diff --git a/Sources/AmoreCheckout/UI/CheckoutWebView.swift b/Sources/AmoreCheckout/UI/CheckoutWebView.swift new file mode 100644 index 0000000..bf25490 --- /dev/null +++ b/Sources/AmoreCheckout/UI/CheckoutWebView.swift @@ -0,0 +1,101 @@ +import AppKit +import SwiftUI +import WebKit + +/// Hosts the checkout page and routes success/cancel redirects to the flow. +struct CheckoutWebView: NSViewRepresentable { + let url: URL + let checkout: AmoreCheckout + let onLoadFailure: (Error) -> Void + + func makeNSView(context: Context) -> WKWebView { + let webView = WKWebView() + webView.navigationDelegate = context.coordinator + webView.uiDelegate = context.coordinator + context.coordinator.load(url, in: webView) + return webView + } + + func updateNSView(_ nsView: WKWebView, context: Context) { + context.coordinator.onLoadFailure = onLoadFailure + context.coordinator.load(url, in: nsView) + } + + func makeCoordinator() -> NavigationDelegate { + NavigationDelegate(checkout: checkout, onLoadFailure: onLoadFailure) + } + + @MainActor + final class NavigationDelegate: NSObject, WKNavigationDelegate, WKUIDelegate { + var onLoadFailure: (Error) -> Void + + private let checkout: AmoreCheckout + private var requestedURL: URL? + + init(checkout: AmoreCheckout, onLoadFailure: @escaping (Error) -> Void) { + self.checkout = checkout + self.onLoadFailure = onLoadFailure + } + + func load(_ url: URL, in webView: WKWebView) { + guard requestedURL != url else { return } + requestedURL = url + webView.load(URLRequest(url: url)) + } + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void + ) { + guard let url = navigationAction.request.url else { + decisionHandler(.allow) + return + } + switch checkout.navigationAction(for: url) { + case .allow: + decisionHandler(.allow) + case .interceptSuccess: + decisionHandler(.cancel) + checkout.handleSuccessRedirect() + case .interceptCancel: + decisionHandler(.cancel) + checkout.handleCancelRedirect() + } + } + + func webView( + _ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + if let url = navigationAction.request.url { + NSWorkspace.shared.open(url) + } + return nil + } + + func webView( + _ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: any Error + ) { + report(error) + } + + func webView( + _ webView: WKWebView, + didFail navigation: WKNavigation!, + withError error: any Error + ) { + report(error) + } + + private func report(_ error: any Error) { + guard (error as NSError).code != NSURLErrorCancelled else { return } + requestedURL = nil + onLoadFailure(error) + } + } +} diff --git a/Sources/AmoreCheckout/UI/LicenseKeyView.swift b/Sources/AmoreCheckout/UI/LicenseKeyView.swift new file mode 100644 index 0000000..d973103 --- /dev/null +++ b/Sources/AmoreCheckout/UI/LicenseKeyView.swift @@ -0,0 +1,46 @@ +import AppKit +import SwiftUI + +struct LicenseKeyView: View { + let licenseKey: String + + @State private var isCopied = false + + var body: some View { + HStack { + Text(licenseKey) + .font(.callout.monospaced()) + .textSelection(.enabled) + Spacer() + copyControl + .buttonStyle(.plain) + .labelStyle(.iconOnly) + .frame(width: 16, height: 16) + } + .task(id: isCopied) { + guard isCopied else { return } + try? await Task.sleep(for: .seconds(2)) + isCopied = false + } + } + + @ViewBuilder + private var copyControl: some View { + if isCopied { + Label("Copied", systemImage: "checkmark.circle.fill") + } else { + Button("Copy", systemImage: "doc.on.doc") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(licenseKey, forType: .string) + isCopied = true + } + } + } +} + +#if DEBUG +#Preview { + LicenseKeyView(licenseKey: "AMORE-4F2A-91C7-BD03") + .padding() +} +#endif diff --git a/Sources/AmoreCheckout/UI/PreviewFixtures.swift b/Sources/AmoreCheckout/UI/PreviewFixtures.swift new file mode 100644 index 0000000..4149361 --- /dev/null +++ b/Sources/AmoreCheckout/UI/PreviewFixtures.swift @@ -0,0 +1,41 @@ +#if DEBUG +import AmoreLicensing +import struct AmoreStore.Price +import struct AmoreStore.Product +import Foundation + +extension Product { + /// A subscription product for previews. + static let preview = Product( + id: UUID(uuidString: "2B9F0C51-7A64-4E0B-9C3D-6F1A8E5D2C47")!, + name: "Acme Pro", + durationInSeconds: nil, + deviceLimit: 3, + price: Price(unitAmount: 4900, currency: "USD", recurringInterval: .year), + checkoutURL: URL(string: "https://checkout.stripe.com/c/pay/preview")! + ) +} + +extension License { + /// An active subscription license for previews. + /// + /// Decoded rather than initialized: `License`'s memberwise initializer is + /// internal to `AmoreLicensing`, so `Codable` is the only way in from here. + static let preview: License = { + let renewsAt = Date(timeIntervalSinceNow: 60 * 60 * 24 * 365) + let json = """ + { + "id": "3A17E7E8-7B5C-4E9D-9E4A-0B2C1D3E4F50", + "product": { "name": "Acme Pro", "identifier": "pro" }, + "entitlements": ["pro"], + "customer": { "email": "grace@example.com" }, + "subscriptionState": { + "state": "renewing", + "renews_at": \(renewsAt.timeIntervalSinceReferenceDate) + } + } + """ + return try! JSONDecoder().decode(License.self, from: Data(json.utf8)) + }() +} +#endif diff --git a/Sources/AmoreCheckout/UI/View+AmoreCheckout.swift b/Sources/AmoreCheckout/UI/View+AmoreCheckout.swift new file mode 100644 index 0000000..b4d32b7 --- /dev/null +++ b/Sources/AmoreCheckout/UI/View+AmoreCheckout.swift @@ -0,0 +1,117 @@ +import struct AmoreStore.Product +import SwiftUI + +extension View { + /// Presents Stripe hosted checkout for `item` in an in-app sheet and + /// activates the purchased license automatically. + /// + /// Setting `item` to a product presents checkout for it; the binding + /// returns to `nil` when the sheet closes. One modifier serves any number + /// of products: + /// + /// ```swift + /// @State private var buying: Product? + /// + /// VStack { + /// Button("Buy Pro") { buying = pro } + /// Button("Buy Team") { buying = team } + /// } + /// .amoreCheckout(item: $buying, checkout: checkout) + /// ``` + /// + /// On completion the license is activated on this device and + /// `AmoreLicensing.status` flips to `.valid`, so gated UI unlocks + /// reactively. + /// + /// See for required entitlements and recovery of + /// interrupted purchases. + /// - Parameters: + /// - item: The product to purchase, from `AmoreStore.products()`. + /// Non-`nil` presents the checkout sheet for that product. + /// - checkout: The app's checkout flow. Inject it idle; the sheet + /// starts it on presentation and rearms it to idle on dismissal. + /// - customerEmail: Prefills the email field on the checkout page. + /// - onResult: Called with the terminal ``CheckoutResult`` of every + /// attempt, including retries after a failure. + @MainActor public func amoreCheckout( + item: Binding, + checkout: AmoreCheckout, + customerEmail: String? = nil, + onResult: ((CheckoutResult) -> Void)? = nil + ) -> some View { + modifier(AmoreCheckoutModifier( + item: item, + checkout: checkout, + completedView: nil, + customerEmail: customerEmail, + onResult: onResult + )) + } + + /// Presents Stripe hosted checkout for `item` in an in-app sheet with a + /// custom success screen, and activates the purchased license + /// automatically. + /// + /// Behaves like + /// ``amoreCheckout(item:checkout:customerEmail:onResult:)``, but the + /// screen shown after a completed purchase is built by `completedView` + /// instead of the built-in one: + /// + /// ```swift + /// .amoreCheckout(item: $buying, checkout: checkout, completedView: { completion in + /// VStack { + /// Text("Welcome aboard!") + /// Button("Continue") { completion.onDone?() } + /// } + /// }) + /// ``` + /// + /// - Parameters: + /// - item: The product to purchase, from `AmoreStore.products()`. + /// Non-`nil` presents the checkout sheet for that product. + /// - checkout: The app's checkout flow. Inject it idle; the sheet + /// starts it on presentation and rearms it to idle on dismissal. + /// - customerEmail: Prefills the email field on the checkout page. + /// - completedView: Builds the success screen from the + /// ``CheckoutCompletion`` once the purchase completes. The sheet stays + /// open until the screen calls ``CheckoutCompletion/onDone``, which is + /// always supplied here. + /// - onResult: Called with the terminal ``CheckoutResult`` of every + /// attempt, including retries after a failure. + @MainActor public func amoreCheckout( + item: Binding, + checkout: AmoreCheckout, + customerEmail: String? = nil, + @ViewBuilder completedView: @escaping (CheckoutCompletion) -> Completed, + onResult: ((CheckoutResult) -> Void)? = nil + ) -> some View { + modifier(AmoreCheckoutModifier( + item: item, + checkout: checkout, + completedView: completedView, + customerEmail: customerEmail, + onResult: onResult + )) + } +} + +@MainActor +private struct AmoreCheckoutModifier: ViewModifier { + @Binding var item: Product? + let checkout: AmoreCheckout + let completedView: ((CheckoutCompletion) -> Completed)? + let customerEmail: String? + let onResult: ((CheckoutResult) -> Void)? + + func body(content: Content) -> some View { + content.sheet(item: $item, onDismiss: checkout.cancel) { product in + CheckoutSheet( + checkout: checkout, + completedView: completedView, + customerEmail: customerEmail, + onResult: { onResult?($0) }, + product: product + ) + } + } +} diff --git a/Sources/AmoreLicensing/Errors/AmoreError.swift b/Sources/AmoreLicensing/Errors/AmoreError.swift index 10b5fc4..9a71e0a 100644 --- a/Sources/AmoreLicensing/Errors/AmoreError.swift +++ b/Sources/AmoreLicensing/Errors/AmoreError.swift @@ -1,7 +1,7 @@ import Foundation /// Errors thrown by AmoreLicensing operations. -public enum AmoreError: LocalizedError, Equatable, Sendable { +public enum AmoreError: LocalizedError, Hashable, Sendable { /// A server-returned client error. case client(ClientError) /// The license token's hardware ID does not match this device. diff --git a/Sources/AmoreLicensing/Errors/ClientError.swift b/Sources/AmoreLicensing/Errors/ClientError.swift index 0e55dd2..244b52b 100644 --- a/Sources/AmoreLicensing/Errors/ClientError.swift +++ b/Sources/AmoreLicensing/Errors/ClientError.swift @@ -1,7 +1,7 @@ import Foundation /// Errors returned by the licensing server. -public enum ClientError: String, LocalizedError, Sendable, Decodable { +public enum ClientError: String, LocalizedError, Sendable, Hashable, Decodable { case activationNotFound case appNotFound case deviceLimitReached diff --git a/Sources/AmoreLicensing/Errors/NetworkError.swift b/Sources/AmoreLicensing/Errors/NetworkError.swift index f27b1b9..6b1959d 100644 --- a/Sources/AmoreLicensing/Errors/NetworkError.swift +++ b/Sources/AmoreLicensing/Errors/NetworkError.swift @@ -1,7 +1,7 @@ import Foundation /// An error indicating a network request to the licensing server failed. -public enum NetworkError: LocalizedError, Equatable, Sendable { +public enum NetworkError: LocalizedError, Hashable, Sendable { case rateLimited case requestFailed(String) diff --git a/Sources/AmoreLicensing/Errors/TokenStoreError.swift b/Sources/AmoreLicensing/Errors/TokenStoreError.swift index df1c3c8..d8145ec 100644 --- a/Sources/AmoreLicensing/Errors/TokenStoreError.swift +++ b/Sources/AmoreLicensing/Errors/TokenStoreError.swift @@ -1,7 +1,7 @@ import Foundation /// Errors from token store operations used to persist license tokens. -public enum TokenStoreError: LocalizedError, Equatable, Sendable { +public enum TokenStoreError: LocalizedError, Hashable, Sendable { case deleteFailed(String) case retrieveFailed(String) case storeFailed(String) diff --git a/Sources/AmoreStore/Documentation.docc/Getting Started.md b/Sources/AmoreStore/Documentation.docc/Getting Started.md index a1355d5..1ba596c 100644 --- a/Sources/AmoreStore/Documentation.docc/Getting Started.md +++ b/Sources/AmoreStore/Documentation.docc/Getting Started.md @@ -54,7 +54,19 @@ Use ``Price/recurringInterval`` to tell one-time purchases from subscriptions. ## Checkout -Every purchasable ``Product`` carries a ``Product/checkoutURL``. Open it to send the customer to Stripe checkout. +Prefer the `AmoreCheckout` module: one view modifier presents Stripe checkout +in-app and activates the purchased license automatically. + +```swift +@State private var buying: Product? + +Button("Buy Pro") { buying = product } + .amoreCheckout(item: $buying, checkout: checkout) +``` + +Alternatively, every purchasable ``Product`` carries a ``Product/checkoutURL`` +you can open in the browser for the classic flow (pay, receive the key by +email, enter it in the app): ```swift NSWorkspace.shared.open(product.checkoutURL) diff --git a/Sources/AmoreStore/Models/Product.swift b/Sources/AmoreStore/Models/Product.swift index 4b776dd..8eed213 100644 --- a/Sources/AmoreStore/Models/Product.swift +++ b/Sources/AmoreStore/Models/Product.swift @@ -13,9 +13,12 @@ public struct Product: Identifiable, Hashable, Codable, Sendable { /// Pricing information, or `nil` when no price is configured. public var price: Price? - /// The checkout URL for this product + /// The web checkout URL for this product. /// - /// Open it to send the customer to checkout, for example: + /// Prefer the `amoreCheckout(item:checkout:)` view modifier from the + /// `AmoreCheckout` module, which presents checkout in-app and activates + /// the purchased license automatically. Open this URL directly only when + /// you want the plain browser flow with no activation or recovery: /// ```swift /// NSWorkspace.shared.open(product.checkoutURL) /// ``` diff --git a/Tests/AmoreCheckoutTests/CheckoutFlowTests.swift b/Tests/AmoreCheckoutTests/CheckoutFlowTests.swift new file mode 100644 index 0000000..1cf06b1 --- /dev/null +++ b/Tests/AmoreCheckoutTests/CheckoutFlowTests.swift @@ -0,0 +1,828 @@ +import Foundation +import Testing + +@testable import AmoreCheckout +@testable import AmoreLicensing +// Scoped import, not `import AmoreStore`: the AmoreStore module also declares +// a public type named `AmoreStore`, so within a file that imports the module, +// `AmoreStore.Product` resolves to a member of that type rather than a member +// of the module and fails to compile. Importing just `Product` sidesteps that +// and still resolves unambiguously against `AmoreLicensing`'s own `Product`. +import struct AmoreStore.Product + +/// Returns queued responses one at a time, repeating the last one. +final class ResponseSequence: @unchecked Sendable { + private var responses: [CheckoutSessionStatusResponse] + + init(_ responses: [CheckoutSessionStatusResponse]) { + self.responses = responses + } + + func next() -> CheckoutSessionStatusResponse { + responses.count > 1 ? responses.removeFirst() : responses[0] + } +} + +@MainActor +// The polling loop is what ends most of these tests, and it is cancellable, so +// a bound that regresses into retrying forever fails here on the time limit +// rather than parking the run until someone kills it. +@Suite(.timeLimit(.minutes(1))) struct CheckoutFlowTests { + private let baseURL = URL(string: "https://api.amore.computer")! + + private func makeProduct() -> Product { + Product( + id: UUID(), + name: "Pro", + durationInSeconds: nil, + deviceLimit: 3, + price: nil, + checkoutURL: URL(string: "https://api.amore.computer/v1/checkout/x")! + ) + } + + private nonisolated func makeSession( + expiresAt: Date? = Date.now.addingTimeInterval(3600) + ) -> CheckoutSession { + CheckoutSession( + sessionId: "cs_1", + checkoutURL: URL(string: "https://checkout.stripe.com/c/pay/cs_1")!, + expiresAt: expiresAt + ) + } + + private func makeStore() throws -> PendingCheckoutStore { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("CheckoutFlowTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return PendingCheckoutStore(directory: directory) + } + + /// A store whose containing directory cannot be created, because a regular + /// file already occupies the path, so every save throws. + private func makeUnwritableStore() throws -> PendingCheckoutStore { + let blocker = FileManager.default.temporaryDirectory + .appendingPathComponent("CheckoutFlowTests-blocked-\(UUID().uuidString)") + try Data().write(to: blocker) + return PendingCheckoutStore(directory: blocker.appendingPathComponent("store")) + } + + private func makeCheckout( + client: MockCheckoutSessionClient, + licensing: MockLicenseActivating, + store: PendingCheckoutStore, + baseURL: URL? = nil + ) -> AmoreCheckout { + AmoreCheckout( + licensing: licensing, + bundleIdentifier: "com.example.app", + baseURL: baseURL ?? self.baseURL, + client: client, + store: store, + pollInterval: .milliseconds(1) + ) + } + + // MARK: - Happy path + + @Test func happyPathActivatesAndCompletes() async throws { + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession()] _, _ in session } + let responses = ResponseSequence([ + CheckoutSessionStatusResponse(status: .open, licenseKey: nil), + CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-1"), + ]) + client.onStatus = { _ in responses.next() } + let licensing = MockLicenseActivating() + let store = try makeStore() + let checkout = makeCheckout(client: client, licensing: licensing, store: store) + + let result = await checkout.start(makeProduct()) + + #expect(licensing.activatedKeys == ["KEY-1"]) + guard case .completed(let license, let licenseKey) = result else { + Issue.record("Expected .completed, got \(result)") + return + } + #expect(license.id == License.fixture.id) + // The issued key travels with the terminal payload so the success + // screen can show it. + #expect(licenseKey == "KEY-1") + guard case .completed(_, licenseKey: let stateKey) = checkout.state else { + Issue.record("Expected .completed state, got \(checkout.state)") + return + } + #expect(stateKey == "KEY-1") + #expect(store.load() == nil) + } + + @Test func startPersistsThePendingSession() async throws { + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession()] _, _ in session } + // Session never completes; the first status response reports expired + // so start() returns instead of polling forever. + client.onStatus = { _ in CheckoutSessionStatusResponse(status: .expired, licenseKey: nil) } + let store = try makeStore() + let checkout = makeCheckout(client: client, licensing: MockLicenseActivating(), store: store) + + await checkout.start(makeProduct()) + + // The pending record was written when checkout opened and survives + // expiry for the recovery path to clean up. + #expect(store.load()?.sessionId == "cs_1") + } + + // MARK: - Failure paths + + @Test func sessionCreationFailureIsTerminal() async throws { + let client = MockCheckoutSessionClient() + client.onCreate = { _, _ in throw CheckoutClientError.httpStatus(502) } + let checkout = makeCheckout( + client: client, licensing: MockLicenseActivating(), store: try makeStore() + ) + + let result = await checkout.start(makeProduct()) + + guard case .failed(.sessionCreationFailed) = result else { + Issue.record("Expected .failed(.sessionCreationFailed), got \(result)") + return + } + guard case .failed(.sessionCreationFailed) = checkout.state else { + Issue.record("Expected .failed state, got \(checkout.state)") + return + } + } + + @Test func pastExpiryStopsPollingWithSessionExpired() async throws { + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession(expiresAt: Date.now.addingTimeInterval(-60))] _, _ in + session + } + client.onStatus = { _ in CheckoutSessionStatusResponse(status: .open, licenseKey: nil) } + let checkout = makeCheckout( + client: client, licensing: MockLicenseActivating(), store: try makeStore() + ) + + let result = await checkout.start(makeProduct()) + + guard case .failed(.sessionExpired) = result else { + Issue.record("Expected .failed(.sessionExpired), got \(result)") + return + } + #expect(client.statusCallCount == 0) + } + + @Test func aPaidSessionOutlivesTheSessionDeadline() async throws { + let client = MockCheckoutSessionClient() + // The deadline runs from the moment the session is created, which is + // the last step before the poll that reads it, so none of its window + // is spent getting the flow that far. + client.onCreate = { [self] _, _ in + makeSession(expiresAt: Date.now.addingTimeInterval(0.1)) + } + let gate = Gate() + let responses = ResponseSequence([ + CheckoutSessionStatusResponse(status: .open, licenseKey: nil), + CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-1"), + ]) + client.onStatus = { _ in + await gate.wait() + return responses.next() + } + let licensing = MockLicenseActivating() + let checkout = makeCheckout(client: client, licensing: licensing, store: try makeStore()) + + // Payment succeeds in the web view, then the session deadline passes + // while the first status response is still in flight. Webhook lag must + // not turn the paid session into .sessionExpired. + let startTask = Task { await checkout.start(makeProduct()) } + await gate.waitForArrival() + checkout.handleSuccessRedirect() + try await Task.sleep(for: .milliseconds(300)) + await gate.open() + let result = await startTask.value + + guard case .completed = result else { + Issue.record("Expected .completed, got \(result)") + return + } + #expect(licensing.activatedKeys == ["KEY-1"]) + } + + /// Checkout finished in the browser has no web view to report the success + /// redirect, so the server status is the only signal that payment landed. + /// Reading it keeps a paid session out of reach of the expiry that bounds + /// an unpaid one, on every surface rather than just the embedded sheet. + @Test func aServerReportedPaymentOutlivesTheSessionDeadline() async throws { + let client = MockCheckoutSessionClient() + client.onCreate = { [self] _, _ in + makeSession(expiresAt: Date.now.addingTimeInterval(0.1)) + } + let gate = Gate() + let responses = ResponseSequence([ + CheckoutSessionStatusResponse(status: .processing, licenseKey: nil), + CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-1"), + ]) + // The session deadline passes while the response that reports the + // payment is still in flight. + client.onStatus = { _ in + let response = responses.next() + if response.status == .processing { await gate.wait() } + return response + } + let licensing = MockLicenseActivating() + let checkout = makeCheckout(client: client, licensing: licensing, store: try makeStore()) + + let startTask = Task { await checkout.start(makeProduct()) } + await gate.waitForArrival() + try await Task.sleep(for: .milliseconds(300)) + await gate.open() + let result = await startTask.value + + guard case .completed = result else { + Issue.record("Expected .completed, got \(result)") + return + } + #expect(licensing.activatedKeys == ["KEY-1"]) + } + + @Test func missingExpiryStillPollsToCompletion() async throws { + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession(expiresAt: nil)] _, _ in session } + let responses = ResponseSequence([ + CheckoutSessionStatusResponse(status: .open, licenseKey: nil), + CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-1"), + ]) + client.onStatus = { _ in responses.next() } + let licensing = MockLicenseActivating() + let checkout = makeCheckout(client: client, licensing: licensing, store: try makeStore()) + + await checkout.start(makeProduct()) + + #expect(licensing.activatedKeys == ["KEY-1"]) + } + + @Test func expiredStatusStopsPolling() async throws { + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession()] _, _ in session } + client.onStatus = { _ in CheckoutSessionStatusResponse(status: .expired, licenseKey: nil) } + let checkout = makeCheckout( + client: client, licensing: MockLicenseActivating(), store: try makeStore() + ) + + let result = await checkout.start(makeProduct()) + + guard case .failed(.sessionExpired) = result else { + Issue.record("Expected .failed(.sessionExpired), got \(result)") + return + } + } + + @Test func transientPollErrorsAreSwallowed() async throws { + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession()] _, _ in session } + let failures = FailOnceThenComplete() + client.onStatus = { _ in try failures.next() } + let licensing = MockLicenseActivating() + let checkout = makeCheckout(client: client, licensing: licensing, store: try makeStore()) + + await checkout.start(makeProduct()) + + #expect(licensing.activatedKeys == ["KEY-1"]) + #expect(client.statusCallCount == 2) + } + + @Test func rateLimitedPollContinuesAndCompletes() async throws { + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession()] _, _ in session } + let failures = FailOnceThenComplete(error: .rateLimited) + client.onStatus = { _ in try failures.next() } + let licensing = MockLicenseActivating() + let checkout = makeCheckout(client: client, licensing: licensing, store: try makeStore()) + + await checkout.start(makeProduct()) + + #expect(licensing.activatedKeys == ["KEY-1"]) + #expect(client.statusCallCount == 2) + } + + @Test func aPaidSessionWaitsForALicenseTheServerHasNotIssuedYet() async throws { + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession()] _, _ in session } + let responses = ResponseSequence([ + CheckoutSessionStatusResponse(status: .complete, licenseKey: nil), + CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-1"), + ]) + client.onStatus = { _ in responses.next() } + let licensing = MockLicenseActivating() + let checkout = makeCheckout(client: client, licensing: licensing, store: try makeStore()) + + // Payment lands before the license exists, the seconds of webhook lag + // every real purchase passes through: the flow keeps waiting for the + // key rather than failing a purchase that succeeded. + let result = await checkout.start(makeProduct()) + + guard case .completed = result else { + Issue.record("Expected .completed, got \(result)") + return + } + #expect(licensing.activatedKeys == ["KEY-1"]) + #expect(client.statusCallCount == 2) + } + + @Test func anUnreachableServerEndsAnUnpaidCheckout() async throws { + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession()] _, _ in session } + client.onStatus = { _ in throw CheckoutClientError.network("offline") } + let checkout = makeCheckout( + client: client, licensing: MockLicenseActivating(), store: try makeStore() + ) + + // Nothing has been paid, so the transport failure is reported rather + // than retried until the session expires and blamed on the expiry. + let result = await checkout.start(makeProduct()) + + guard case .failed(.pollingFailed(let underlying)) = result else { + Issue.record("Expected .failed(.pollingFailed), got \(result)") + return + } + #expect(underlying == .network("offline")) + #expect(client.statusCallCount == AmoreCheckout.maxPollingFailures + 1) + } + + @Test func aSessionTheServerHasLostIsReportedAsExpired() async throws { + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession()] _, _ in session } + client.onStatus = { _ in throw CheckoutClientError.httpStatus(404) } + let checkout = makeCheckout( + client: client, licensing: MockLicenseActivating(), store: try makeStore() + ) + + // A session the server has no record of will not come back on a later + // tick, and to the user it is an expired one. + let result = await checkout.start(makeProduct()) + + guard case .failed(.sessionExpired) = result else { + Issue.record("Expected .failed(.sessionExpired), got \(result)") + return + } + #expect(client.statusCallCount == 1) + } + + @Test func aPaidSessionKeepsPollingThroughNetworkFailures() async throws { + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession()] _, _ in session } + let responses = OfflineAfterPayment(failures: AmoreCheckout.maxPollingFailures + 4) + client.onStatus = { _ in try responses.next() } + let licensing = MockLicenseActivating() + let checkout = makeCheckout(client: client, licensing: licensing, store: try makeStore()) + + // The bound that protects an unpaid checkout must not apply once + // payment lands: giving up here would report a transport error for a + // purchase that succeeded. + let result = await checkout.start(makeProduct()) + + guard case .completed = result else { + Issue.record("Expected .completed, got \(result)") + return + } + #expect(licensing.activatedKeys == ["KEY-1"]) + } + + @Test func activationFailureCarriesTheLicenseKey() async throws { + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession()] _, _ in session } + client.onStatus = { _ in CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-1") } + let licensing = MockLicenseActivating() + licensing.activationError = .client(.deviceLimitReached) + let store = try makeStore() + let checkout = makeCheckout(client: client, licensing: licensing, store: store) + + let result = await checkout.start(makeProduct()) + + guard case .failed(.activationFailed(let key, let underlying)) = result else { + Issue.record("Expected .failed(.activationFailed), got \(result)") + return + } + #expect(key == "KEY-1") + #expect(underlying == .client(.deviceLimitReached)) + guard case .failed(.activationFailed) = checkout.state else { + Issue.record("Expected .failed state, got \(checkout.state)") + return + } + #expect(licensing.activatedKeys == ["KEY-1"]) + // The payment went through, so the pending record has to outlive the + // failed activation: it is what the next launch's recovery picks up. + #expect(store.load() != nil) + } + + @Test func startRearmsFromAFailedState() async throws { + let client = MockCheckoutSessionClient() + client.onCreate = { _, _ in throw CheckoutClientError.httpStatus(502) } + let licensing = MockLicenseActivating() + let checkout = makeCheckout(client: client, licensing: licensing, store: try makeStore()) + + let first = await checkout.start(makeProduct()) + guard case .failed = first else { + Issue.record("Expected .failed, got \(first)") + return + } + + // The server recovers; a plain second start() must run a fresh attempt. + client.onCreate = { [session = makeSession()] _, _ in session } + client.onStatus = { _ in CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-2") } + let second = await checkout.start(makeProduct()) + + guard case .completed = second else { + Issue.record("Expected .completed, got \(second)") + return + } + #expect(licensing.activatedKeys == ["KEY-2"]) + } + + @Test func reentrantStartJoinsTheFlowInProgress() async throws { + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession()] _, _ in session } + let gate = Gate() + client.onStatus = { _ in + await gate.wait() + return CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-1") + } + let licensing = MockLicenseActivating() + let checkout = makeCheckout(client: client, licensing: licensing, store: try makeStore()) + + // A double-clicked retry calls start() again mid-flight; the second + // call must join the running attempt, not crash or report .cancelled. + let first = Task { await checkout.start(makeProduct()) } + await gate.waitForArrival() + let second = Task { await checkout.start(makeProduct()) } + await gate.open() + let firstResult = await first.value + let secondResult = await second.value + + guard case .completed = firstResult else { + Issue.record("Expected .completed, got \(firstResult)") + return + } + guard case .completed = secondResult else { + Issue.record("Expected .completed, got \(secondResult)") + return + } + #expect(licensing.activatedKeys == ["KEY-1"]) + } + + // MARK: - Recovery + + @Test func recoverPendingPurchaseUsesTheInjectedClientAndStore() async throws { + let store = try makeStore() + try store.save(PendingCheckout(sessionId: "cs_9", productId: UUID(), createdAt: .now)) + let client = MockCheckoutSessionClient() + client.onStatus = { _ in CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-9") } + let licensing = MockLicenseActivating() + let checkout = makeCheckout(client: client, licensing: licensing, store: store) + + let license = await checkout.recoverPendingPurchase() + + #expect(license?.id == License.fixture.id) + #expect(licensing.activatedKeys == ["KEY-9"]) + #expect(store.load() == nil) + } + + // MARK: - Navigation interception + + @Test func navigationMatchingRoutesServerPages() throws { + let checkout = makeCheckout( + client: MockCheckoutSessionClient(), licensing: MockLicenseActivating(), store: try makeStore() + ) + + let success = URL(string: "https://api.amore.computer/checkout/success?session_id=cs_1&product=x")! + let cancel = URL(string: "https://api.amore.computer/checkout/cancel")! + let stripe = URL(string: "https://checkout.stripe.com/c/pay/cs_1")! + let unrelated = URL(string: "https://api.amore.computer/portal")! + let wrongHostSuccess = URL(string: "https://evil.example.com/checkout/success")! + let wrongSchemeSuccess = URL(string: "http://api.amore.computer/checkout/success")! + + #expect(checkout.navigationAction(for: success) == .interceptSuccess) + #expect(checkout.navigationAction(for: cancel) == .interceptCancel) + #expect(checkout.navigationAction(for: stripe) == .allow) + #expect(checkout.navigationAction(for: unrelated) == .allow) + #expect(checkout.navigationAction(for: wrongHostSuccess) == .allow) + #expect(checkout.navigationAction(for: wrongSchemeSuccess) == .allow) + } + + @Test func navigationMatchingRespectsBaseURLPath() throws { + let checkout = makeCheckout( + client: MockCheckoutSessionClient(), + licensing: MockLicenseActivating(), + store: try makeStore(), + baseURL: URL(string: "https://staging.example.com/api")! + ) + + let success = URL(string: "https://staging.example.com/api/checkout/success?session_id=1")! + let wrongPath = URL(string: "https://staging.example.com/checkout/success")! + + #expect(checkout.navigationAction(for: success) == .interceptSuccess) + #expect(checkout.navigationAction(for: wrongPath) == .allow) + } + + // MARK: - Cancellation + + @Test func cancelEndsAnInFlightStartWithCancelled() async throws { + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession()] _, _ in session } + let gate = Gate() + client.onStatus = { _ in + await gate.wait() + return CheckoutSessionStatusResponse(status: .open, licenseKey: nil) + } + let store = try makeStore() + let checkout = makeCheckout(client: client, licensing: MockLicenseActivating(), store: store) + + let startTask = Task { await checkout.start(makeProduct()) } + await gate.waitForArrival() + checkout.cancel() + await gate.open() + let result = await startTask.value + + guard case .cancelled = result else { + Issue.record("Expected .cancelled, got \(result)") + return + } + guard case .idle = checkout.state else { + Issue.record("Expected .idle, got \(checkout.state)") + return + } + // Unlike the cancel redirect, a plain cancel is ambiguous (the user + // may still pay in a browser tab), so the record stays recoverable. + #expect(store.load() != nil) + } + + @Test func cancelDuringActivationIsRefused() async throws { + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession()] _, _ in session } + client.onStatus = { _ in CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-1") } + let licensing = MockLicenseActivating() + let gate = Gate() + licensing.activateGate = gate + let checkout = makeCheckout(client: client, licensing: licensing, store: try makeStore()) + + // Payment already succeeded; cancelling mid-activation is refused so + // the paid session always reaches a terminal state. + let startTask = Task { await checkout.start(makeProduct()) } + await gate.waitForArrival() + checkout.cancel() + guard case .activating = checkout.state else { + Issue.record("Expected cancel to be refused in .activating, got \(checkout.state)") + return + } + await gate.open() + let result = await startTask.value + + guard case .completed = result else { + Issue.record("Expected .completed, got \(result)") + return + } + #expect(licensing.activatedKeys == ["KEY-1"]) + } + + @Test func cancelClearsATerminalStateSoTheNextStartBeginsFresh() async throws { + let client = MockCheckoutSessionClient() + client.onCreate = { _, _ in throw CheckoutClientError.httpStatus(502) } + let checkout = makeCheckout( + client: client, licensing: MockLicenseActivating(), store: try makeStore() + ) + + await checkout.start(makeProduct()) + guard case .failed = checkout.state else { + Issue.record("Expected .failed, got \(checkout.state)") + return + } + checkout.cancel() + guard case .idle = checkout.state else { + Issue.record("Expected .idle, got \(checkout.state)") + return + } + } + + @Test func surroundingTaskCancellationAfterActivationStillCompletes() async throws { + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession()] _, _ in session } + client.onStatus = { _ in CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-1") } + let licensing = MockLicenseActivating() + let gate = Gate() + licensing.activateGate = gate + let store = try makeStore() + let checkout = makeCheckout(client: client, licensing: licensing, store: store) + + // The hosting view leaves the hierarchy mid-activation. The license is + // activated anyway, so the purchase is reported as completed and its + // pending record cleared: reporting .cancelled here would leave the + // record behind for the next launch to activate a second time. + let startTask = Task { await checkout.start(makeProduct()) } + await gate.waitForArrival() + startTask.cancel() + await gate.open() + let result = await startTask.value + + guard case .completed = result else { + Issue.record("Expected .completed, got \(result)") + return + } + guard case .completed = checkout.state else { + Issue.record("Expected .completed, got \(checkout.state)") + return + } + #expect(licensing.activatedKeys == ["KEY-1"]) + #expect(store.load() == nil) + } + + @Test func anUnrecordableSessionStopsCheckoutBeforePayment() async throws { + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession()] _, _ in session } + let checkout = makeCheckout( + client: client, licensing: MockLicenseActivating(), store: try makeUnwritableStore() + ) + + let result = await checkout.start(makeProduct()) + + guard case .failed(.checkoutNotRecordable) = result else { + Issue.record("Expected .failed(.checkoutNotRecordable), got \(result)") + return + } + // A session that was never recorded cannot be recovered, so the user + // must never reach a page they could pay on. + #expect(checkout.checkoutURL == nil) + #expect(client.statusCallCount == 0) + } + + @Test func surroundingTaskCancellationEndsTheFlow() async throws { + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession()] _, _ in session } + let gate = Gate() + client.onStatus = { _ in + await gate.wait() + return CheckoutSessionStatusResponse(status: .open, licenseKey: nil) + } + let checkout = makeCheckout(client: client, licensing: MockLicenseActivating(), store: try makeStore()) + + // The hosting view leaves the hierarchy while checkout is polling: the + // task is cancelled directly and the flow must end on its own. + let startTask = Task { await checkout.start(makeProduct()) } + await gate.waitForArrival() + startTask.cancel() + await gate.open() + let result = await startTask.value + + guard case .cancelled = result else { + Issue.record("Expected .cancelled, got \(result)") + return + } + guard case .idle = checkout.state else { + Issue.record("Expected .idle, got \(checkout.state)") + return + } + } + + @Test func cancelRedirectAbandonsTheFlowButKeepsThePendingRecord() async throws { + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession()] _, _ in session } + let gate = Gate() + client.onStatus = { _ in + await gate.wait() + return CheckoutSessionStatusResponse(status: .open, licenseKey: nil) + } + let store = try makeStore() + let checkout = makeCheckout(client: client, licensing: MockLicenseActivating(), store: store) + + let startTask = Task { await checkout.start(makeProduct()) } + await gate.waitForArrival() + #expect(checkout.checkoutURL != nil) + checkout.handleCancelRedirect() + await gate.open() + let result = await startTask.value + + guard case .cancelled = result else { + Issue.record("Expected .cancelled, got \(result)") + return + } + guard case .idle = checkout.state else { + Issue.record("Expected .idle, got \(checkout.state)") + return + } + // The cancel page ends the flow but is not proof the payment failed, + // so the record survives for the next start() to resolve against the + // server. + #expect(store.load() != nil) + } + + @Test func cancelRedirectAfterPaymentKeepsTheFlowAndThePendingRecord() async throws { + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession()] _, _ in session } + client.onStatus = { _ in CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-1") } + let licensing = MockLicenseActivating() + let gate = Gate() + licensing.activateGate = gate + let store = try makeStore() + let checkout = makeCheckout(client: client, licensing: licensing, store: store) + + // The user reaches the cancel page after paying, by going back or by + // the page redirecting late. Abandoning here would clear the record + // that makes the payment recoverable, so the redirect is ignored and + // the flow runs to its terminal state. + let startTask = Task { await checkout.start(makeProduct()) } + await gate.waitForArrival() + checkout.handleCancelRedirect() + if case .activating = checkout.state {} else { + Issue.record("Expected the cancel redirect to be ignored in .activating, got \(checkout.state)") + } + #expect(store.load() != nil) + await gate.open() + let result = await startTask.value + + guard case .completed = result else { + Issue.record("Expected .completed, got \(result)") + return + } + #expect(licensing.activatedKeys == ["KEY-1"]) + } + + @Test func successRedirectMovesToActivating() async throws { + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession()] _, _ in session } + let gate = Gate() + client.onStatus = { _ in + await gate.wait() + return CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-1") + } + let licensing = MockLicenseActivating() + let checkout = makeCheckout(client: client, licensing: licensing, store: try makeStore()) + + let startTask = Task { await checkout.start(makeProduct()) } + await gate.waitForArrival() + checkout.handleSuccessRedirect() + guard case .activating = checkout.state else { + Issue.record("Expected .activating, got \(checkout.state)") + return + } + await gate.open() + let result = await startTask.value + + guard case .completed = result else { + Issue.record("Expected .completed, got \(result)") + return + } + } + + @Test func successRedirectOutsideAFlowIsIgnored() async throws { + let checkout = makeCheckout( + client: MockCheckoutSessionClient(), licensing: MockLicenseActivating(), store: try makeStore() + ) + + checkout.handleSuccessRedirect() + + guard case .idle = checkout.state else { + Issue.record("Expected .idle, got \(checkout.state)") + return + } + } +} + +/// Reports payment, then fails a run of status checks before issuing the key. +final class OfflineAfterPayment: @unchecked Sendable { + private var hasReportedPayment = false + private var remainingFailures: Int + + init(failures: Int) { + remainingFailures = failures + } + + func next() throws(CheckoutClientError) -> CheckoutSessionStatusResponse { + if !hasReportedPayment { + hasReportedPayment = true + return CheckoutSessionStatusResponse(status: .complete, licenseKey: nil) + } + if remainingFailures > 0 { + remainingFailures -= 1 + throw .network("offline") + } + return CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-1") + } +} + +/// Throws once, then reports completion. Exercises the swallow-and-retry path. +final class FailOnceThenComplete: @unchecked Sendable { + private let error: CheckoutClientError + private var hasFailed = false + + init(error: CheckoutClientError = .network("connection lost")) { + self.error = error + } + + func next() throws(CheckoutClientError) -> CheckoutSessionStatusResponse { + if !hasFailed { + hasFailed = true + throw error + } + return CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-1") + } +} diff --git a/Tests/AmoreCheckoutTests/CheckoutModelTests.swift b/Tests/AmoreCheckoutTests/CheckoutModelTests.swift new file mode 100644 index 0000000..d2b9874 --- /dev/null +++ b/Tests/AmoreCheckoutTests/CheckoutModelTests.swift @@ -0,0 +1,116 @@ +import Foundation +import Testing + +@testable import AmoreCheckout +@testable import AmoreLicensing + +@Suite struct CheckoutModelTests { + private var decoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } + + @Test func decodesCheckoutSession() throws { + let json = """ + { + "checkoutURL": "https://checkout.stripe.com/c/pay/cs_test_123", + "expiresAt": "2026-07-18T10:00:00Z", + "sessionId": "cs_test_123" + } + """ + let session = try decoder.decode(CheckoutSession.self, from: Data(json.utf8)) + + #expect(session.sessionId == "cs_test_123") + #expect(session.checkoutURL == URL(string: "https://checkout.stripe.com/c/pay/cs_test_123")) + #expect(session.expiresAt == ISO8601DateFormatter().date(from: "2026-07-18T10:00:00Z")) + } + + @Test func decodesSessionWithoutExpiry() throws { + let json = """ + { "checkoutURL": "https://checkout.stripe.com/c/pay/cs_1", "sessionId": "cs_1" } + """ + let session = try decoder.decode(CheckoutSession.self, from: Data(json.utf8)) + + #expect(session.expiresAt == nil) + } + + @Test func decodesCompleteStatusWithKey() throws { + let json = """ + { "licenseKey": "AAAAA-BBBBB-CCCCC-DDDDD-EEEEE-FFFFF-00000-11111", "status": "complete" } + """ + let status = try decoder.decode(CheckoutSessionStatusResponse.self, from: Data(json.utf8)) + + #expect(status.status == .complete) + #expect(status.licenseKey == "AAAAA-BBBBB-CCCCC-DDDDD-EEEEE-FFFFF-00000-11111") + } + + @Test func decodesOpenStatusWithoutKey() throws { + let json = """ + { "status": "open" } + """ + let status = try decoder.decode(CheckoutSessionStatusResponse.self, from: Data(json.utf8)) + + #expect(status.status == .open) + #expect(status.licenseKey == nil) + } + + @Test func onlyPrePaymentStatesAreCancellable() { + let url = URL(string: "https://checkout.stripe.com/c/pay/cs_1")! + #expect(CheckoutState.preparing.isCancellable) + #expect(CheckoutState.awaitingPayment(url).isCancellable) + #expect(!CheckoutState.idle.isCancellable) + #expect(!CheckoutState.activating.isCancellable) + #expect(!CheckoutState.completed(.fixture, licenseKey: "KEY-1").isCancellable) + #expect(!CheckoutState.failed(.sessionExpired).isCancellable) + } + + @Test func sessionCreationFailureSurfacesTheClientMessage() { + let error = CheckoutError.sessionCreationFailed(underlying: .rateLimited) + #expect(error.errorDescription == "Too many requests. Please try again later.") + } + + @Test func unresolvedPendingSessionReadsAsAVerificationFailure() { + // Distinct from .sessionCreationFailed: an earlier purchase could not + // be verified, so the message must not read as a failure to start. + let withUnderlying = CheckoutError.pendingSessionUnresolved(underlying: .rateLimited) + let withoutUnderlying = CheckoutError.pendingSessionUnresolved(underlying: nil) + let expected = "Your previous purchase could not be confirmed. Please try again." + + #expect(withUnderlying.errorDescription == expected) + #expect(withoutUnderlying.errorDescription == expected) + } + + @Test func pollDeadlineUsesTheServerExpiry() { + let expiry = Date.now.addingTimeInterval(600) + let session = CheckoutSession( + sessionId: "cs_1", + checkoutURL: URL(string: "https://checkout.stripe.com/c/pay/cs_1")!, + expiresAt: expiry + ) + #expect(session.pollDeadline(from: .now) == expiry) + } + + @Test func pollDeadlineWithoutServerExpiryIsBoundedByTheSessionCeiling() { + let start = Date.now + let session = CheckoutSession( + sessionId: "cs_1", + checkoutURL: URL(string: "https://checkout.stripe.com/c/pay/cs_1")!, + expiresAt: nil + ) + #expect(session.pollDeadline(from: start) == start.addingTimeInterval(24 * 60 * 60)) + } + + @Test func decodesAllStatusValues() throws { + for (raw, expected) in [ + ("open", CheckoutSessionStatus.open), + ("processing", .processing), + ("complete", .complete), + ("expired", .expired), + ] { + let json = #"{ "status": "\#(raw)" }"# + let status = try decoder.decode(CheckoutSessionStatusResponse.self, from: Data(json.utf8)) + #expect(status.status == expected) + } + } +} diff --git a/Tests/AmoreCheckoutTests/CheckoutRecoveryTests.swift b/Tests/AmoreCheckoutTests/CheckoutRecoveryTests.swift new file mode 100644 index 0000000..6632510 --- /dev/null +++ b/Tests/AmoreCheckoutTests/CheckoutRecoveryTests.swift @@ -0,0 +1,295 @@ +import Foundation +import Testing + +@testable import AmoreCheckout +@testable import AmoreLicensing +import struct AmoreStore.Product + +@MainActor +@Suite(.timeLimit(.minutes(1))) struct CheckoutRecoveryTests { + private func makeProduct(id: UUID) -> Product { + Product( + id: id, + name: "Pro", + durationInSeconds: nil, + deviceLimit: 3, + price: nil, + checkoutURL: URL(string: "https://api.amore.computer/v1/checkout/x")! + ) + } + + private func makeStore() throws -> PendingCheckoutStore { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("CheckoutRecoveryTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return PendingCheckoutStore(directory: directory) + } + + private func makeCheckout( + client: MockCheckoutSessionClient, + licensing: MockLicenseActivating, + store: PendingCheckoutStore + ) -> AmoreCheckout { + AmoreCheckout( + licensing: licensing, + bundleIdentifier: "com.example.app", + client: client, + store: store, + pollInterval: .milliseconds(1) + ) + } + + @Test func activatesCompletedPendingSession() async throws { + let store = try makeStore() + try store.save(PendingCheckout(sessionId: "cs_9", productId: UUID(), createdAt: .now)) + let client = MockCheckoutSessionClient() + client.onStatus = { sessionId in + #expect(sessionId == "cs_9") + return CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-9") + } + let licensing = MockLicenseActivating() + let checkout = makeCheckout(client: client, licensing: licensing, store: store) + + let license = await checkout.recoverPendingPurchase() + + #expect(licensing.activatedKeys == ["KEY-9"]) + #expect(license?.id == License.fixture.id) + #expect(store.load() == nil) + } + + @Test func clearsExpiredPendingSession() async throws { + let store = try makeStore() + try store.save(PendingCheckout(sessionId: "cs_9", productId: UUID(), createdAt: .now)) + let client = MockCheckoutSessionClient() + client.onStatus = { _ in CheckoutSessionStatusResponse(status: .expired, licenseKey: nil) } + let licensing = MockLicenseActivating() + let checkout = makeCheckout(client: client, licensing: licensing, store: store) + + let license = await checkout.recoverPendingPurchase() + + #expect(license == nil) + #expect(store.load() == nil) + #expect(licensing.activatedKeys.isEmpty) + } + + @Test func clearsStalePendingWithoutNetworkCall() async throws { + let store = try makeStore() + try store.save(PendingCheckout( + sessionId: "cs_9", + productId: UUID(), + createdAt: Date.now.addingTimeInterval(-25 * 60 * 60) + )) + let client = MockCheckoutSessionClient() + let checkout = makeCheckout(client: client, licensing: MockLicenseActivating(), store: store) + + let license = await checkout.recoverPendingPurchase() + + #expect(license == nil) + #expect(store.load() == nil) + #expect(client.statusCallCount == 0) + } + + @Test func clearsAnUnpaidPendingSessionWhenAlreadyLicensed() async throws { + let store = try makeStore() + try store.save(PendingCheckout(sessionId: "cs_9", productId: UUID(), createdAt: .now)) + let client = MockCheckoutSessionClient() + client.onStatus = { _ in CheckoutSessionStatusResponse(status: .open, licenseKey: nil) } + let licensing = MockLicenseActivating() + licensing.status = .valid(.fixture) + let checkout = makeCheckout(client: client, licensing: licensing, store: store) + + let license = await checkout.recoverPendingPurchase() + + #expect(license == nil) + #expect(licensing.activatedKeys.isEmpty) + // The abandoned record would otherwise be re-checked on every launch. + #expect(store.load() == nil) + } + + /// A license for one product says nothing about a paid record for another, + /// so recovery resolves the record instead of discarding it: dropping it + /// would leave the purchase to be charged a second time. + @Test func activatesAPaidPendingSessionWhenAlreadyLicensed() async throws { + let store = try makeStore() + try store.save(PendingCheckout(sessionId: "cs_9", productId: UUID(), createdAt: .now)) + let client = MockCheckoutSessionClient() + client.onStatus = { _ in CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-9") } + let licensing = MockLicenseActivating() + licensing.status = .valid(.fixture) + let checkout = makeCheckout(client: client, licensing: licensing, store: store) + + let license = await checkout.recoverPendingPurchase() + + #expect(licensing.activatedKeys == ["KEY-9"]) + #expect(license?.id == License.fixture.id) + #expect(store.load() == nil) + } + + @Test func leavesOpenPendingSessionInPlace() async throws { + let store = try makeStore() + try store.save(PendingCheckout(sessionId: "cs_9", productId: UUID(), createdAt: .now)) + let client = MockCheckoutSessionClient() + client.onStatus = { _ in CheckoutSessionStatusResponse(status: .open, licenseKey: nil) } + let checkout = makeCheckout(client: client, licensing: MockLicenseActivating(), store: store) + + let license = await checkout.recoverPendingPurchase() + + #expect(license == nil) + #expect(store.load()?.sessionId == "cs_9") + } + + @Test func reportsAnActivationThatWillNotSucceed() async throws { + let store = try makeStore() + try store.save(PendingCheckout(sessionId: "cs_9", productId: UUID(), createdAt: .now)) + let client = MockCheckoutSessionClient() + client.onStatus = { _ in CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-9") } + let licensing = MockLicenseActivating() + licensing.activationError = .client(.deviceLimitReached) + let checkout = makeCheckout(client: client, licensing: licensing, store: store) + + let license = await checkout.recoverPendingPurchase() + + // Silence would retry this at every launch and then discard the record + // at maxPendingAge, leaving a customer who paid with nothing to show. + #expect(license == nil) + guard case .failed(.activationFailed(let key, let underlying)) = checkout.state else { + Issue.record("Expected .failed(.activationFailed), got \(checkout.state)") + return + } + #expect(key == "KEY-9") + #expect(underlying == .client(.deviceLimitReached)) + #expect(store.load() != nil) + } + + @Test func leavesThePendingRecordWhenTheStatusCheckFails() async throws { + let store = try makeStore() + try store.save(PendingCheckout(sessionId: "cs_9", productId: UUID(), createdAt: .now)) + let client = MockCheckoutSessionClient() + client.onStatus = { _ in throw CheckoutClientError.network("offline") } + let checkout = makeCheckout(client: client, licensing: MockLicenseActivating(), store: store) + + let license = await checkout.recoverPendingPurchase() + + // An unreachable server says nothing about the purchase, so recovery + // stays quiet and leaves the record for the next launch. + #expect(license == nil) + #expect(store.load()?.sessionId == "cs_9") + guard case .idle = checkout.state else { + Issue.record("Expected .idle, got \(checkout.state)") + return + } + } + + @Test func returnsNilWhenNothingIsPending() async throws { + let client = MockCheckoutSessionClient() + let checkout = makeCheckout( + client: client, licensing: MockLicenseActivating(), store: try makeStore() + ) + + let license = await checkout.recoverPendingPurchase() + + #expect(license == nil) + #expect(client.statusCallCount == 0) + } + + // MARK: - Concurrency + + @Test func aSecondRecoveryJoinsTheOneInFlight() async throws { + let store = try makeStore() + try store.save(PendingCheckout(sessionId: "cs_9", productId: UUID(), createdAt: .now)) + let client = MockCheckoutSessionClient() + client.onStatus = { _ in CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-9") } + let licensing = MockLicenseActivating() + let gate = Gate() + licensing.activateGate = gate + let checkout = makeCheckout(client: client, licensing: licensing, store: store) + + let first = Task { await checkout.recoverPendingPurchase() } + await gate.waitForArrival() + let second = Task { await checkout.recoverPendingPurchase() } + await gate.open() + let firstLicense = await first.value + let secondLicense = await second.value + + #expect(licensing.activatedKeys == ["KEY-9"]) + #expect(firstLicense == secondLicense) + } + + @Test func recoveringDuringACheckoutWaitsForItInsteadOfActivatingTwice() async throws { + let store = try makeStore() + let client = MockCheckoutSessionClient() + client.onCreate = { _, _ in + CheckoutSession( + sessionId: "cs_new", + checkoutURL: URL(string: "https://checkout.stripe.com/c/pay/cs_new")!, + expiresAt: Date.now.addingTimeInterval(3600) + ) + } + client.onStatus = { _ in CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-1") } + let licensing = MockLicenseActivating() + let gate = Gate() + licensing.activateGate = gate + let checkout = makeCheckout(client: client, licensing: licensing, store: store) + + // The app recovers on foregrounding while a checkout is mid-activation. + // Recovery waits for the flow rather than reading the same pending + // record and activating its key a second time. + let buying = Task { await checkout.start(makeProduct(id: UUID())) } + await gate.waitForArrival() + let recovering = Task { await checkout.recoverPendingPurchase() } + await gate.open() + let result = await buying.value + let license = await recovering.value + + #expect(licensing.activatedKeys == ["KEY-1"]) + #expect(license?.id == License.fixture.id) + guard case .completed = result else { + Issue.record("Expected .completed, got \(result)") + return + } + } + + @Test func buyingDuringRecoveryWaitsForItInsteadOfChargingAgain() async throws { + let productId = UUID() + let store = try makeStore() + try store.save(PendingCheckout(sessionId: "cs_9", productId: productId, createdAt: .now)) + let client = MockCheckoutSessionClient() + client.onCreate = { _, _ in + CheckoutSession( + sessionId: "cs_new", + checkoutURL: URL(string: "https://checkout.stripe.com/c/pay/cs_new")!, + expiresAt: Date.now.addingTimeInterval(3600) + ) + } + client.onStatus = { sessionId in + sessionId == "cs_9" + ? CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-9") + : CheckoutSessionStatusResponse(status: .open, licenseKey: nil) + } + let licensing = MockLicenseActivating() + let gate = Gate() + licensing.activateGate = gate + let checkout = makeCheckout(client: client, licensing: licensing, store: store) + + // The buy button is tapped while launch recovery is mid-activation, so + // the paywall is still up for a purchase that has already been paid + // for. The flow waits for recovery, then stops on the license it + // activated: the same key is never activated twice, and the customer is + // never shown a second checkout for a product they now own. `onCreate` + // is wired so that lapse would show up as a session rather than a crash. + let recovering = Task { await checkout.recoverPendingPurchase() } + await gate.waitForArrival() + let buying = Task { await checkout.start(makeProduct(id: productId)) } + await gate.open() + #expect(await recovering.value != nil) + let result = await buying.value + + #expect(licensing.activatedKeys == ["KEY-9"]) + #expect(client.createCallCount == 0) + #expect(checkout.state == .idle) + guard case .cancelled = result else { + Issue.record("Expected .cancelled, got \(result)") + return + } + } +} diff --git a/Tests/AmoreCheckoutTests/CheckoutResumeTests.swift b/Tests/AmoreCheckoutTests/CheckoutResumeTests.swift new file mode 100644 index 0000000..c464328 --- /dev/null +++ b/Tests/AmoreCheckoutTests/CheckoutResumeTests.swift @@ -0,0 +1,428 @@ +import Foundation +import Testing + +@testable import AmoreCheckout +@testable import AmoreLicensing +import struct AmoreStore.Product + +/// `start()` resolves a persisted pending session before creating a new one, +/// so an interrupted purchase can never turn into a second charge. +@MainActor +@Suite(.timeLimit(.minutes(1))) struct CheckoutResumeTests { + private func makeProduct() -> Product { + Product( + id: UUID(), + name: "Pro", + durationInSeconds: nil, + deviceLimit: 3, + price: nil, + checkoutURL: URL(string: "https://api.amore.computer/v1/checkout/x")! + ) + } + + private func makeSession() -> CheckoutSession { + CheckoutSession( + sessionId: "cs_new", + checkoutURL: URL(string: "https://checkout.stripe.com/c/pay/cs_new")!, + expiresAt: Date.now.addingTimeInterval(3600) + ) + } + + private func makeStore() throws -> PendingCheckoutStore { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("CheckoutResumeTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return PendingCheckoutStore(directory: directory) + } + + private func makeCheckout( + client: MockCheckoutSessionClient, + licensing: MockLicenseActivating, + store: PendingCheckoutStore + ) -> AmoreCheckout { + AmoreCheckout( + licensing: licensing, + bundleIdentifier: "com.example.app", + client: client, + store: store, + pollInterval: .milliseconds(1) + ) + } + + private func savePending( + to store: PendingCheckoutStore, + productId: UUID = UUID(), + age: TimeInterval = 0, + expiresAt: Date? = nil + ) throws { + try store.save(PendingCheckout( + sessionId: "cs_old", + productId: productId, + createdAt: Date.now.addingTimeInterval(-age), + expiresAt: expiresAt + )) + } + + @Test func startActivatesACompletedPendingSessionInsteadOfCharging() async throws { + let store = try makeStore() + try savePending(to: store) + let client = MockCheckoutSessionClient() + client.onStatus = { sessionId in + #expect(sessionId == "cs_old") + return CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-OLD") + } + let licensing = MockLicenseActivating() + let checkout = makeCheckout(client: client, licensing: licensing, store: store) + + let result = await checkout.start(makeProduct()) + + guard case .completed = result else { + Issue.record("Expected .completed, got \(result)") + return + } + #expect(licensing.activatedKeys == ["KEY-OLD"]) + #expect(client.createCallCount == 0) + #expect(store.load() == nil) + } + + @Test func startResumesPollingAProcessingPendingSession() async throws { + let store = try makeStore() + try savePending(to: store) + let client = MockCheckoutSessionClient() + let responses = ResponseSequence([ + CheckoutSessionStatusResponse(status: .processing, licenseKey: nil), + CheckoutSessionStatusResponse(status: .processing, licenseKey: nil), + CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-OLD"), + ]) + client.onStatus = { _ in responses.next() } + let licensing = MockLicenseActivating() + let checkout = makeCheckout(client: client, licensing: licensing, store: store) + + let result = await checkout.start(makeProduct()) + + guard case .completed = result else { + Issue.record("Expected .completed, got \(result)") + return + } + #expect(licensing.activatedKeys == ["KEY-OLD"]) + #expect(client.createCallCount == 0) + #expect(store.load() == nil) + } + + /// A resumed session that is already being paid for polls as an + /// activation, not as an open checkout: it is out of reach of the Close + /// button, and out of reach of the expiry that bounds an unpaid session + /// (`passedDeadlineWhileActivatingDoesNotExpireTheSession` covers the + /// latter). + @Test func aResumedProcessingSessionActivatesAndRefusesCancellation() async throws { + let store = try makeStore() + try savePending(to: store) + let client = MockCheckoutSessionClient() + let gate = Gate() + let responses = ResponseSequence([ + CheckoutSessionStatusResponse(status: .processing, licenseKey: nil), + CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-OLD"), + ]) + // The first call is the resume check; the poll that follows is held + // open so the test can try to abandon a session already paid for. + client.onStatus = { _ in + let response = responses.next() + if response.status == .complete { await gate.wait() } + return response + } + let licensing = MockLicenseActivating() + let checkout = makeCheckout(client: client, licensing: licensing, store: store) + let product = makeProduct() + + let flow = Task { await checkout.start(product) } + await gate.waitForArrival() + if case .activating = checkout.state {} else { + Issue.record("Expected .activating, got \(checkout.state)") + } + checkout.cancel() + await gate.open() + + let result = await flow.value + guard case .completed = result else { + Issue.record("Expected .completed, got \(result)") + return + } + #expect(licensing.activatedKeys == ["KEY-OLD"]) + } + + @Test func aPaidSessionStopsPollingForALicenseThatNeverArrives() async throws { + let store = try makeStore() + try savePending(to: store) + let client = MockCheckoutSessionClient() + // The webhook that issues the license never lands. + client.onStatus = { _ in CheckoutSessionStatusResponse(status: .processing, licenseKey: nil) } + let checkout = makeCheckout(client: client, licensing: MockLicenseActivating(), store: store) + + let result = await checkout.start(makeProduct()) + + guard case .failed(.licenseIssuanceTimedOut) = result else { + Issue.record("Expected .failed(.licenseIssuanceTimedOut), got \(result)") + return + } + // The resume check plus the bounded run of polls that followed it. + #expect(client.statusCallCount == AmoreCheckout.maxActivationPolls + 1) + #expect(client.createCallCount == 0) + // The purchase survives the failure: a retry, or the next launch, + // resumes the same session rather than charging again. + #expect(store.load() != nil) + } + + @Test func startReopensAnOpenPendingSessionForTheSameProduct() async throws { + let store = try makeStore() + let product = makeProduct() + try savePending( + to: store, productId: product.id, expiresAt: Date.now.addingTimeInterval(3600) + ) + let client = MockCheckoutSessionClient() + // The interrupted checkout is still open on the server; the flow + // returns to that session instead of opening a second one. + let responses = ResponseSequence([ + CheckoutSessionStatusResponse(status: .open, licenseKey: nil), + CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-OLD"), + ]) + client.onStatus = { sessionId in + #expect(sessionId == "cs_old") + return responses.next() + } + let licensing = MockLicenseActivating() + let checkout = makeCheckout(client: client, licensing: licensing, store: store) + + let result = await checkout.start(product) + + guard case .completed = result else { + Issue.record("Expected .completed, got \(result)") + return + } + #expect(licensing.activatedKeys == ["KEY-OLD"]) + #expect(client.createCallCount == 0) + #expect(store.load() == nil) + } + + @Test func startDoesNotReopenAnOpenSessionPastItsExpiry() async throws { + let store = try makeStore() + let product = makeProduct() + try savePending( + to: store, productId: product.id, expiresAt: Date.now.addingTimeInterval(-60) + ) + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession()] _, _ in session } + client.onStatus = { sessionId in + sessionId == "cs_old" + ? CheckoutSessionStatusResponse(status: .open, licenseKey: nil) + : CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-NEW") + } + let licensing = MockLicenseActivating() + let checkout = makeCheckout(client: client, licensing: licensing, store: store) + + let result = await checkout.start(product) + + guard case .completed = result else { + Issue.record("Expected .completed, got \(result)") + return + } + #expect(licensing.activatedKeys == ["KEY-NEW"]) + #expect(client.createCallCount == 1) + } + + @Test func startWithAnOpenSessionForAnotherProductCreatesAFreshSession() async throws { + let store = try makeStore() + try savePending(to: store, expiresAt: Date.now.addingTimeInterval(3600)) + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession()] _, _ in session } + // The open session belongs to a different product than the one being + // bought now, so it is superseded; only the new session completes. + client.onStatus = { sessionId in + sessionId == "cs_old" + ? CheckoutSessionStatusResponse(status: .open, licenseKey: nil) + : CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-NEW") + } + let licensing = MockLicenseActivating() + let checkout = makeCheckout(client: client, licensing: licensing, store: store) + + let result = await checkout.start(makeProduct()) + + guard case .completed = result else { + Issue.record("Expected .completed, got \(result)") + return + } + #expect(licensing.activatedKeys == ["KEY-NEW"]) + #expect(client.createCallCount == 1) + } + + @Test func startWithAStalePendingRecordSkipsTheStatusCheck() async throws { + let store = try makeStore() + try savePending(to: store, age: 25 * 60 * 60) + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession()] _, _ in session } + client.onStatus = { sessionId in + #expect(sessionId == "cs_new") + return CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-NEW") + } + let licensing = MockLicenseActivating() + let checkout = makeCheckout(client: client, licensing: licensing, store: store) + + let result = await checkout.start(makeProduct()) + + guard case .completed = result else { + Issue.record("Expected .completed, got \(result)") + return + } + #expect(licensing.activatedKeys == ["KEY-NEW"]) + #expect(client.statusCallCount == 1) + } + + @Test func startWhileLicensedSkipsAnUnpaidPendingSessionAndChargesFresh() async throws { + let store = try makeStore() + try savePending(to: store) + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession()] _, _ in session } + // A licensed user starting a checkout is a deliberate second + // purchase; an unpaid leftover must not hijack or block it. + client.onStatus = { sessionId in + sessionId == "cs_old" + ? CheckoutSessionStatusResponse(status: .open, licenseKey: nil) + : CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-NEW") + } + let licensing = MockLicenseActivating() + licensing.status = .valid(.fixture) + let checkout = makeCheckout(client: client, licensing: licensing, store: store) + + let result = await checkout.start(makeProduct()) + + guard case .completed = result else { + Issue.record("Expected .completed, got \(result)") + return + } + #expect(licensing.activatedKeys == ["KEY-NEW"]) + #expect(client.createCallCount == 1) + } + + /// Holding a license says nothing about a record that was already paid + /// for: a second product bought on the way out of the app leaves exactly + /// that. Discarding it unchecked would charge for the same purchase twice. + @Test func startWhileLicensedResolvesAPaidPendingSessionInsteadOfCharging() async throws { + let store = try makeStore() + try savePending(to: store) + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession()] _, _ in session } + client.onStatus = { sessionId in + #expect(sessionId == "cs_old") + return CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-OLD") + } + let licensing = MockLicenseActivating() + licensing.status = .valid(.fixture) + let checkout = makeCheckout(client: client, licensing: licensing, store: store) + + let result = await checkout.start(makeProduct()) + + guard case .completed = result else { + Issue.record("Expected .completed, got \(result)") + return + } + #expect(licensing.activatedKeys == ["KEY-OLD"]) + #expect(client.createCallCount == 0) + #expect(store.load() == nil) + } + + @Test func startFailsWhenThePendingStatusCheckFails() async throws { + let store = try makeStore() + try savePending(to: store) + let client = MockCheckoutSessionClient() + client.onStatus = { _ in throw CheckoutClientError.network("connection lost") } + let checkout = makeCheckout(client: client, licensing: MockLicenseActivating(), store: store) + + // The pending session might already be paid; charging again without + // knowing would be the double purchase this guard exists to prevent. + let result = await checkout.start(makeProduct()) + + guard case .failed(.pendingSessionUnresolved) = result else { + Issue.record("Expected .failed(.pendingSessionUnresolved), got \(result)") + return + } + #expect(client.createCallCount == 0) + #expect(store.load() != nil) + } + + @Test func startFailsWhenAPaidPendingSessionHasNoLicenseKey() async throws { + let store = try makeStore() + try savePending(to: store) + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession()] _, _ in session } + // A paid session whose key is missing contradicts the API contract, so + // the flow fails rather than charging for a purchase already made. The + // record survives for a retry, which picks the key up once it appears. + client.onStatus = { _ in CheckoutSessionStatusResponse(status: .complete, licenseKey: nil) } + let licensing = MockLicenseActivating() + let checkout = makeCheckout(client: client, licensing: licensing, store: store) + + let result = await checkout.start(makeProduct()) + + guard case .failed(.pendingSessionUnresolved) = result else { + Issue.record("Expected .failed(.pendingSessionUnresolved), got \(result)") + return + } + #expect(client.createCallCount == 0) + #expect(licensing.activatedKeys.isEmpty) + #expect(store.load() != nil) + } + + @Test func startDiscardsAPendingSessionTheServerHasNoRecordOf() async throws { + let store = try makeStore() + try savePending(to: store) + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession()] _, _ in session } + // A session the server never heard of cannot resolve on a retry the + // way a transient failure can, so it is dropped rather than holding + // every later purchase behind a check that can never pass. + client.onStatus = { sessionId in + if sessionId == "cs_old" { throw CheckoutClientError.httpStatus(404) } + return CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-NEW") + } + let licensing = MockLicenseActivating() + let checkout = makeCheckout(client: client, licensing: licensing, store: store) + + let result = await checkout.start(makeProduct()) + + guard case .completed = result else { + Issue.record("Expected .completed, got \(result)") + return + } + #expect(client.createCallCount == 1) + #expect(licensing.activatedKeys == ["KEY-NEW"]) + #expect(store.load() == nil) + } + + @Test func retryAfterAnUnresolvedPendingSessionActivatesItInsteadOfCharging() async throws { + let store = try makeStore() + try savePending(to: store) + let client = MockCheckoutSessionClient() + client.onCreate = { [session = makeSession()] _, _ in session } + client.onStatus = { _ in throw CheckoutClientError.network("connection lost") } + let licensing = MockLicenseActivating() + let checkout = makeCheckout(client: client, licensing: licensing, store: store) + + let first = await checkout.start(makeProduct()) + guard case .failed(.pendingSessionUnresolved) = first else { + Issue.record("Expected .failed(.pendingSessionUnresolved), got \(first)") + return + } + + // Connectivity returns and the session turns out to have been paid all + // along: the retry activates it, never reaching a second charge. + client.onStatus = { _ in CheckoutSessionStatusResponse(status: .complete, licenseKey: "KEY-OLD") } + let second = await checkout.start(makeProduct()) + + guard case .completed = second else { + Issue.record("Expected .completed, got \(second)") + return + } + #expect(licensing.activatedKeys == ["KEY-OLD"]) + #expect(client.createCallCount == 0) + #expect(store.load() == nil) + } +} diff --git a/Tests/AmoreCheckoutTests/CheckoutSessionClientTests.swift b/Tests/AmoreCheckoutTests/CheckoutSessionClientTests.swift new file mode 100644 index 0000000..ef91f16 --- /dev/null +++ b/Tests/AmoreCheckoutTests/CheckoutSessionClientTests.swift @@ -0,0 +1,141 @@ +import Foundation +import Testing + +@testable import AmoreCheckout + +/// Captures the URLRequest a stubbed transport received. +private final class CapturedRequest: @unchecked Sendable { + var request: URLRequest? +} + +@Suite struct CheckoutSessionClientTests { + private let baseURL = URL(string: "https://api.amore.computer")! + private let bundleId = "com.example.app" + + private func makeClient( + status: Int = 200, + body: String, + captured: CapturedRequest = CapturedRequest() + ) -> HTTPCheckoutSessionClient { + HTTPCheckoutSessionClient( + baseURL: baseURL, + bundleIdentifier: bundleId, + transport: { request in + captured.request = request + let response = HTTPURLResponse( + url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil + )! + return (Data(body.utf8), response) + } + ) + } + + @Test func createSessionBuildsRequestAndDecodesResponse() async throws { + let captured = CapturedRequest() + let client = makeClient( + status: 201, + body: """ + { + "checkoutURL": "https://checkout.stripe.com/c/pay/cs_1", + "expiresAt": "2026-07-18T10:00:00Z", + "sessionId": "cs_1" + } + """, + captured: captured + ) + let productId = UUID() + + let session = try await client.createSession(productId: productId, customerEmail: "b@example.com") + + #expect(session.sessionId == "cs_1") + #expect(session.checkoutURL == URL(string: "https://checkout.stripe.com/c/pay/cs_1")) + + let request = try #require(captured.request) + #expect(request.url?.absoluteString + == "https://api.amore.computer/v1/public/apps/com.example.app/checkout/sessions") + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + + let body = try #require(request.httpBody) + let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: String]) + #expect(json["productId"] == productId.uuidString) + #expect(json["customerEmail"] == "b@example.com") + } + + @Test func createSessionOmitsNilEmail() async throws { + let captured = CapturedRequest() + let client = makeClient( + status: 201, + body: #"{ "checkoutURL": "https://c.example/s", "sessionId": "cs_1" }"#, + captured: captured + ) + + _ = try await client.createSession(productId: UUID(), customerEmail: nil) + + let body = try #require(captured.request?.httpBody) + let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: String]) + #expect(json["customerEmail"] == nil) + } + + @Test func sessionStatusBuildsRequestAndDecodesResponse() async throws { + let captured = CapturedRequest() + let client = makeClient( + status: 200, + body: #"{ "licenseKey": "KEY-1", "status": "complete" }"#, + captured: captured + ) + + let status = try await client.sessionStatus(sessionId: "cs_42") + + #expect(status.status == .complete) + #expect(status.licenseKey == "KEY-1") + let request = try #require(captured.request) + #expect(request.url?.absoluteString + == "https://api.amore.computer/v1/public/apps/com.example.app/checkout/sessions/cs_42") + #expect(request.httpMethod == "GET") + } + + @Test func throwsOnNon2xxStatus() async throws { + let client = makeClient(status: 502, body: "") + + await #expect(throws: CheckoutClientError.httpStatus(502)) { + try await client.sessionStatus(sessionId: "cs_1") + } + } + + @Test func throwsRateLimitedOn429() async throws { + let client = makeClient(status: 429, body: "") + + await #expect(throws: CheckoutClientError.rateLimited) { + try await client.sessionStatus(sessionId: "cs_1") + } + } + + @Test func wrapsTransportFailuresAsNetworkError() async throws { + let client = HTTPCheckoutSessionClient( + baseURL: baseURL, + bundleIdentifier: bundleId, + transport: { _ in throw URLError(.notConnectedToInternet) } + ) + + await #expect(throws: CheckoutClientError.network( + URLError(.notConnectedToInternet).localizedDescription + )) { + try await client.sessionStatus(sessionId: "cs_1") + } + } + + @Test func throwsOnNonHTTPResponse() async throws { + let client = HTTPCheckoutSessionClient( + baseURL: baseURL, + bundleIdentifier: bundleId, + transport: { request in (Data(), URLResponse( + url: request.url!, mimeType: nil, expectedContentLength: 0, textEncodingName: nil + )) } + ) + + await #expect(throws: CheckoutClientError.invalidResponse) { + try await client.sessionStatus(sessionId: "cs_1") + } + } +} diff --git a/Tests/AmoreCheckoutTests/Mocks.swift b/Tests/AmoreCheckoutTests/Mocks.swift new file mode 100644 index 0000000..98538a5 --- /dev/null +++ b/Tests/AmoreCheckoutTests/Mocks.swift @@ -0,0 +1,138 @@ +import Foundation + +@testable import AmoreCheckout +@testable import AmoreLicensing + +final class MockCheckoutSessionClient: CheckoutSessionClient, @unchecked Sendable { + /// The closures throw untyped so tests can write plain literals; anything + /// that is not already a `CheckoutClientError` is narrowed to `.network`. + var onCreate: (@Sendable (UUID, String?) async throws -> CheckoutSession)? + var onStatus: (@Sendable (String) async throws -> CheckoutSessionStatusResponse)? + private(set) var createCallCount = 0 + private(set) var statusCallCount = 0 + + func createSession(productId: UUID, customerEmail: String?) async throws(CheckoutClientError) -> CheckoutSession { + createCallCount += 1 + guard let onCreate else { throw CheckoutClientError.invalidResponse } + return try await narrowing { try await onCreate(productId, customerEmail) } + } + + func sessionStatus(sessionId: String) async throws(CheckoutClientError) -> CheckoutSessionStatusResponse { + statusCallCount += 1 + guard let onStatus else { throw CheckoutClientError.invalidResponse } + return try await narrowing { try await onStatus(sessionId) } + } + + private func narrowing(_ body: () async throws -> T) async throws(CheckoutClientError) -> T { + do { + return try await body() + } catch let error as CheckoutClientError { + throw error + } catch { + throw CheckoutClientError.network(error.localizedDescription) + } + } +} + +@MainActor +final class MockLicenseActivating: LicenseActivating { + var status: ValidationStatus = .unknown + var activationError: AmoreError? + /// When set, `activate(licenseKey:)` suspends here before doing anything + /// else, so a test can interleave other coordinator calls mid-activation. + var activateGate: Gate? + private(set) var activatedKeys: [String] = [] + + func activate(licenseKey: String) async throws(AmoreError) { + if let activateGate { + await activateGate.wait() + } + activatedKeys.append(licenseKey) + if let activationError { throw activationError } + status = .valid(.fixture) + } +} + +/// A one-shot suspension point for tests that need to pause a mock mid-call, +/// confirm the awaiting code actually reached that point, then release it and +/// observe how the resumed call is handled. +actor Gate { + private var isOpen = false + private var hasArrived = false + private var waiters: [CheckedContinuation] = [] + private var arrivalWaiters: [CheckedContinuation] = [] + + /// Suspends the caller until `open()` is called. + func wait() async { + hasArrived = true + releaseArrivalWaiters() + if isOpen { return } + await withCheckedContinuation { waiters.append($0) } + } + + /// Suspends the caller until some other task has called `wait()`, giving + /// up after `timeout`. + /// + /// A flow that ends before it ever reaches the gate leaves nobody to + /// announce an arrival, and a plain continuation would park the test until + /// the whole run is killed. Giving up hands the failure to the assertions + /// that follow, where it reads as the wrong result rather than a hang. + func waitForArrival(timeout: Duration = .seconds(5)) async { + if hasArrived { return } + let timer = Task { [weak self] in + try? await Task.sleep(for: timeout) + await self?.releaseArrivalWaiters() + } + await withCheckedContinuation { arrivalWaiters.append($0) } + timer.cancel() + } + + /// Releases every caller currently suspended in `wait()`, and any future ones. + func open() { + isOpen = true + let pending = waiters + waiters = [] + for waiter in pending { waiter.resume() } + } + + private func releaseArrivalWaiters() { + let pending = arrivalWaiters + arrivalWaiters = [] + for waiter in pending { waiter.resume() } + } +} + +extension PendingCheckout { + /// Builds the embedded session from its parts, so tests that only care + /// about identity and age stay flat. + init(sessionId: String, productId: UUID, createdAt: Date, expiresAt: Date? = nil) { + self.init( + session: CheckoutSession( + sessionId: sessionId, + checkoutURL: URL(string: "https://checkout.stripe.com/c/pay/\(sessionId)")!, + expiresAt: expiresAt + ), + productId: productId, + createdAt: createdAt + ) + } +} + +extension License { + /// A minimal valid license for coordinator tests. Uses the internal + /// memberwise initializer via @testable import. + /// + /// Written as `License`/`Product` rather than the module-qualified + /// `AmoreLicensing.License`/`AmoreLicensing.Product`: the `AmoreLicensing` + /// module also declares a public type named `AmoreLicensing`, so within a + /// file that imports the module, `AmoreLicensing.X` resolves to a member + /// of that type rather than a member of the module and fails to compile. + static let fixture = License( + id: UUID(uuidString: "11111111-1111-1111-1111-111111111111")!, + product: Product(name: "Pro", identifier: "pro"), + expiresAt: nil, + entitlements: [], + subscriptionState: nil, + customer: nil + ) +} diff --git a/Tests/AmoreCheckoutTests/PendingCheckoutStoreTests.swift b/Tests/AmoreCheckoutTests/PendingCheckoutStoreTests.swift new file mode 100644 index 0000000..d7e37a8 --- /dev/null +++ b/Tests/AmoreCheckoutTests/PendingCheckoutStoreTests.swift @@ -0,0 +1,69 @@ +import Foundation +import Testing + +@testable import AmoreCheckout + +@Suite struct PendingCheckoutStoreTests { + private func makeTempDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("PendingCheckoutStoreTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + @Test func savesAndLoadsRoundTrip() throws { + let directory = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = PendingCheckoutStore(directory: directory) + let pending = PendingCheckout( + sessionId: "cs_1", + productId: UUID(), + createdAt: Date(timeIntervalSince1970: 1_752_000_000) + ) + + try store.save(pending) + + #expect(store.load() == pending) + } + + @Test func loadReturnsNilWhenNothingStored() throws { + let directory = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = PendingCheckoutStore(directory: directory) + + #expect(store.load() == nil) + } + + @Test func loadReturnsNilForCorruptFile() throws { + let directory = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = PendingCheckoutStore(directory: directory) + try Data("not json".utf8).write( + to: directory.appendingPathComponent(PendingCheckoutStore.fileName) + ) + + #expect(store.load() == nil) + } + + @Test func clearRemovesTheStoredCheckout() throws { + let directory = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = PendingCheckoutStore(directory: directory) + try store.save(PendingCheckout(sessionId: "cs_1", productId: UUID(), createdAt: .now)) + + store.clear() + + #expect(store.load() == nil) + } + + @Test func clearIsIdempotent() throws { + let directory = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = PendingCheckoutStore(directory: directory) + + store.clear() + store.clear() + + #expect(store.load() == nil) + } +}