From 367d2f01e31da0168f125505885c0e555957fb4b Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:59:45 +0800 Subject: [PATCH 1/2] feat: add Swift subscription client and views --- .gitignore | 5 + Package.swift | 20 + README.md | 148 +++- Sources/RxSubscriptionIOS/Client.swift | 728 ++++++++++++++++++ Sources/RxSubscriptionIOS/Models.swift | 694 +++++++++++++++++ .../RxSubscriptionIOS/RxSubscriptionIOS.swift | 5 + .../RxSubscriptionIOS/StoreKitSupport.swift | 58 ++ .../Views/BalanceHistoryView.swift | 146 ++++ .../RxSubscriptionIOS/Views/BalanceView.swift | 60 ++ .../RxSubscriptionIOS/Views/Components.swift | 559 ++++++++++++++ .../RxSubscriptionIOS/Views/PaywallView.swift | 112 +++ .../Views/PreviewSupport.swift | 140 ++++ .../Views/SubscriptionPlanView.swift | 389 ++++++++++ .../RxSubscriptionIOS/Views/TopUpView.swift | 292 +++++++ .../RxSubscriptionIOS/Views/UsageView.swift | 68 ++ .../RxSubscriptionIOSTests/ClientTests.swift | 314 ++++++++ .../RxSubscriptionIOSTests.swift | 1 + 17 files changed, 3738 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 Package.swift create mode 100644 Sources/RxSubscriptionIOS/Client.swift create mode 100644 Sources/RxSubscriptionIOS/Models.swift create mode 100644 Sources/RxSubscriptionIOS/RxSubscriptionIOS.swift create mode 100644 Sources/RxSubscriptionIOS/StoreKitSupport.swift create mode 100644 Sources/RxSubscriptionIOS/Views/BalanceHistoryView.swift create mode 100644 Sources/RxSubscriptionIOS/Views/BalanceView.swift create mode 100644 Sources/RxSubscriptionIOS/Views/Components.swift create mode 100644 Sources/RxSubscriptionIOS/Views/PaywallView.swift create mode 100644 Sources/RxSubscriptionIOS/Views/PreviewSupport.swift create mode 100644 Sources/RxSubscriptionIOS/Views/SubscriptionPlanView.swift create mode 100644 Sources/RxSubscriptionIOS/Views/TopUpView.swift create mode 100644 Sources/RxSubscriptionIOS/Views/UsageView.swift create mode 100644 Tests/RxSubscriptionIOSTests/ClientTests.swift create mode 100644 Tests/RxSubscriptionIOSTests/RxSubscriptionIOSTests.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..48bfcc8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +/.build +/.swiftpm +xcuserdata/ +DerivedData/ diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..7ce8523 --- /dev/null +++ b/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "RxSubscriptionIOS", + platforms: [ + .iOS(.v26), + .macOS(.v26), + ], + products: [ + .library(name: "RxSubscriptionIOS", targets: ["RxSubscriptionIOS"]), + ], + targets: [ + .target(name: "RxSubscriptionIOS"), + .testTarget( + name: "RxSubscriptionIOSTests", + dependencies: ["RxSubscriptionIOS"] + ), + ] +) diff --git a/README.md b/README.md index 40850ab..2e4320d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,148 @@ # RxSubscriptionIOS -iOS package for RxSubscription service + +`RxSubscriptionIOS` is a Swift Package for the RxSubscription backend. It includes: + +- a typed client for every public `/api/v1` endpoint; +- StoreKit 2 product loading, purchase fulfillment, and restore support; +- reusable SwiftUI plan, top-up, usage, balance, and balance-history screens; +- a section-selectable paywall with a host-app supplied SwiftUI header. + +The package supports iOS 16+, macOS 13+, and Swift 5.9+. + +## Add the package + +In Xcode, choose **File → Add Package Dependencies → Add Local…** and select this folder. Import the library where it is used: + +```swift +import RxSubscriptionIOS +``` + +## Configure a client + +Create one client for the currently signed-in RxLab user. The API key controls whether the server uses sandbox or production data. + +```swift +let subscriptions = Client( + serverURL: URL(string: "https://subscription.example.com")!, + apiKey: configuration.subscriptionAPIKey, + rxlabUserID: session.userID, + email: session.email, + displayName: session.displayName +) +``` + +Do not place an unrestricted or unrelated server credential in an app bundle. Mobile app secrets can be extracted. Use a dedicated, revocable application key for this backend contract and rotate it if the app is compromised. + +## SwiftUI views + +Each view can be used independently: + +```swift +SubscriptionPlanView(client: subscriptions) +TopUpView(client: subscriptions) +UsageView(client: subscriptions) +BalanceView(client: subscriptions) +BalanceHistoryView(client: subscriptions) +``` + +Plans and top-ups accept a custom header: + +```swift +SubscriptionPlanView(client: subscriptions) { + VStack(alignment: .leading, spacing: 8) { + Text("Choose your plan") + .font(.largeTitle.bold()) + Text("Upgrade, restore, or keep using the free tier.") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) +} +``` + +Use `PaywallView` to expose one section or a segmented set of sections with a single custom header: + +```swift +PaywallView( + client: subscriptions, + sections: [.plans, .topUps, .usage, .balances], + initialSection: .plans +) { + PaywallHeroView() +} +``` + +For a focused screen, pass only one section: + +```swift +PaywallView(client: subscriptions, sections: [.usage]) +``` + +## StoreKit lifecycle + +When an Apple mapping is present in the catalog, the views prefer StoreKit and show Apple's localized price. The package performs the required sequence: + +1. requests the stable App Store account token from the backend; +2. attaches it as StoreKit's `appAccountToken`; +3. purchases and verifies the StoreKit transaction locally; +4. submits the signed JWS to the backend for authoritative fulfillment; +5. finishes the StoreKit transaction only after fulfillment succeeds. + +The plan screen includes Restore Purchases. You can also invoke StoreKit directly: + +```swift +let outcome = try await subscriptions.purchaseApple( + productID: "com.example.pro.monthly" +) + +let restored = try await subscriptions.restoreApplePurchases() +``` + +Enable the **In-App Purchase** capability in the containing app target. Products and Notifications V2 still need to be configured in App Store Connect and in the RxSubscription console. + +## Client API + +The client covers the backend's complete public API surface. + +| Area | Methods | +| --- | --- | +| Catalog and access | `catalog`, `entitlements` | +| Balances | `balances`, `adjustBalance`, `ledger`, `consumptionStatistics` | +| Reservations | `reserveBalance`, both `reservation` overloads, `increaseReservation`, `settleReservation`, `releaseReservation` | +| Usage | `usage`, `recordUsage`, `usageStatistics` | +| Stripe | `checkoutPlan`, `checkoutTopUp`, `billingPortal`, `validateCoupon`, `invoices` | +| Purchases | `purchases` | +| App Store bridge | `appleAccountToken`, `setAppleConsumptionConsent`, `submitAppleTransaction` | +| StoreKit | `storeProducts`, `purchaseApple`, `restoreApplePurchases` | + +Metadata values use the package's `JSONValue` type. Balance and usage mutations preserve the server's idempotency contract. In particular, supply stable idempotency keys when retrying balance mutations or reservation operations. + +`BillingProvider.googlePlay` is present as the future provider discriminator. The package intentionally exposes no inactive Google Play purchase endpoint because the backend does not have one yet. + +## Direct API examples + +```swift +let entitlement = try await subscriptions.entitlements() +let currentUsage = try await subscriptions.usage() +let history = try await subscriptions.ledger(page: 1, pageSize: 20) + +let result = try await subscriptions.recordUsage( + item: "generation", + amount: 1, + idempotencyKey: requestID +) + +guard result.allowed else { + // `reason` is `limit_exceeded` or `insufficient_balance`. + return +} +``` + +The backend returns usage denials with HTTP 402. `recordUsage` decodes those responses as `UsageRecordResult` rather than throwing, so callers can handle `allowed == false` normally. + +## Tests + +```sh +swift test +``` + +The test suite verifies authentication and user scoping, StoreKit catalog decoding, error payloads, HTTP 402 usage behavior, date handling, formatting, and request/response coverage for every public backend route. diff --git a/Sources/RxSubscriptionIOS/Client.swift b/Sources/RxSubscriptionIOS/Client.swift new file mode 100644 index 0000000..d1776da --- /dev/null +++ b/Sources/RxSubscriptionIOS/Client.swift @@ -0,0 +1,728 @@ +import Foundation + +public struct UserIdentity: Hashable, Sendable { + public let rxlabUserID: String + public let email: String? + public let displayName: String? + + public init(rxlabUserID: String, email: String? = nil, displayName: String? = nil) { + self.rxlabUserID = rxlabUserID + self.email = email + self.displayName = displayName + } +} + +public enum ClientError: Error, LocalizedError { + case invalidConfiguration(String) + case invalidURL + case invalidResponse + case server(statusCode: Int, payload: APIErrorPayload?, responseBody: String?) + case storeProductNotFound(String) + case unverifiedStoreTransaction + + public var errorDescription: String? { + switch self { + case .invalidConfiguration(let message): return message + case .invalidURL: return "The subscription server URL is invalid." + case .invalidResponse: return "The subscription server returned an invalid response." + case .server(_, let payload, let body): + return payload?.errorDescription ?? payload?.error ?? body ?? "The subscription request failed." + case .storeProductNotFound(let id): return "App Store product not found: \(id)" + case .unverifiedStoreTransaction: return "StoreKit could not verify the transaction on this device." + } + } +} + +/// Application-scoped client for the RxSubscription HTTP and StoreKit APIs. +/// +/// Create one client for the signed-in user and pass it directly to the package's +/// SwiftUI views. The backend API key determines sandbox versus production. +@MainActor +public final class Client { + public let serverURL: URL + public let apiKey: String + public let user: UserIdentity + + private let session: URLSession + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + public init( + serverURL: URL, + apiKey: String, + rxlabUserID: String, + email: String? = nil, + displayName: String? = nil, + session: URLSession = .shared + ) { + self.serverURL = serverURL + self.apiKey = apiKey + self.user = UserIdentity( + rxlabUserID: rxlabUserID, + email: email, + displayName: displayName + ) + self.session = session + self.encoder = JSONEncoder() + self.decoder = Self.makeDecoder() + } + + // MARK: Storefront and entitlements + + public func catalog(includeEligibility: Bool = true) async throws -> Catalog { + try await get( + "api/v1/catalog", + query: includeEligibility ? [query("rxlabUserId", user.rxlabUserID)] : [] + ) + } + + public func entitlements() async throws -> Entitlements { + try await get( + "api/v1/entitlements", + query: userQuery(includeProfile: true) + ) + } + + public func balances() async throws -> [Balance] { + let response: BalancesResponse = try await get( + "api/v1/balances", + query: [query("rxlabUserId", user.rxlabUserID)] + ) + return response.balances + } + + public func adjustBalance( + unit: String, + amount: Int, + operation: BalanceOperation, + description: String = "API adjustment", + idempotencyKey: String, + metadata: [String: JSONValue]? = nil + ) async throws -> BalanceMutationResult { + try await send( + "POST", + path: "api/v1/balances", + body: BalanceMutationBody( + rxlabUserID: user.rxlabUserID, + unit: unit, + amount: amount, + operation: operation, + description: description, + idempotencyKey: idempotencyKey, + metadata: metadata + ) + ) + } + + public func ledger( + unit: String? = nil, + page: Int = 1, + pageSize: Int = 20 + ) async throws -> LedgerPage { + try await get( + "api/v1/balances/ledger", + query: [ + query("rxlabUserId", user.rxlabUserID), + optionalQuery("unit", unit), + query("page", page), + query("pageSize", pageSize), + ].compactMap { $0 } + ) + } + + // MARK: Balance reservations + + public func reserveBalance( + unit: String, + amount: Int, + idempotencyKey: String, + description: String = "Balance reservation", + metadata: [String: JSONValue]? = nil, + expiresInSeconds: Int = 1_800 + ) async throws -> BalanceReservationResult { + try await send( + "POST", + path: "api/v1/balances/reserve", + body: ReserveBalanceBody( + rxlabUserID: user.rxlabUserID, + unit: unit, + amount: amount, + idempotencyKey: idempotencyKey, + description: description, + metadata: metadata, + expiresInSeconds: expiresInSeconds + ) + ) + } + + public func reservation(id: String) async throws -> BalanceReservation { + let response: BalanceReservationResponse = try await get( + "api/v1/balances/reservations/\(pathComponent(id))" + ) + return response.reservation + } + + public func reservation(idempotencyKey: String) async throws -> BalanceReservation { + let response: BalanceReservationResponse = try await get( + "api/v1/balances/reservations", + query: [query("idempotencyKey", idempotencyKey)] + ) + return response.reservation + } + + public func increaseReservation( + id: String, + amount: Int, + idempotencyKey: String + ) async throws -> BalanceReservationResult { + try await send( + "POST", + path: "api/v1/balances/reservations/\(pathComponent(id))/increase", + body: IncreaseReservationBody(amount: amount, idempotencyKey: idempotencyKey) + ) + } + + public func settleReservation( + id: String, + amount: Int, + idempotencyKey: String, + final: Bool = false, + description: String? = nil, + metadata: [String: JSONValue]? = nil + ) async throws -> ReservationSettlement { + try await send( + "POST", + path: "api/v1/balances/reservations/\(pathComponent(id))/settle", + body: SettleReservationBody( + amount: amount, + idempotencyKey: idempotencyKey, + final: final, + description: description, + metadata: metadata + ) + ) + } + + public func releaseReservation( + id: String, + idempotencyKey: String, + reason: String? = nil + ) async throws -> ReservationRelease { + try await send( + "POST", + path: "api/v1/balances/reservations/\(pathComponent(id))/release", + body: ReleaseReservationBody(idempotencyKey: idempotencyKey, reason: reason) + ) + } + + // MARK: Usage and statistics + + public func usage() async throws -> [UsageStatus] { + let response: UsageResponse = try await get( + "api/v1/usage", + query: [query("rxlabUserId", user.rxlabUserID)] + ) + return response.usage + } + + /// Records a metered event. A 402 response is decoded as a normal result with `allowed == false`. + public func recordUsage( + item: String, + amount: Int = 1, + idempotencyKey: String? = nil, + metadata: [String: JSONValue]? = nil + ) async throws -> UsageRecordResult { + try await send( + "POST", + path: "api/v1/usage", + body: RecordUsageBody( + rxlabUserID: user.rxlabUserID, + item: item, + amount: amount, + idempotencyKey: idempotencyKey, + metadata: metadata + ), + acceptedStatusCodes: Set(200...299).union([402]) + ) + } + + public func usageStatistics( + from: Date, + to: Date, + granularity: SeriesGranularity = .day, + item: String? = nil, + groupByItem: Bool = false, + forCurrentUser: Bool = true + ) async throws -> UsageSeries { + try await get( + "api/v1/usage/statistics", + query: seriesQuery(from: from, to: to, granularity: granularity) + [ + optionalQuery("rxlabUserId", forCurrentUser ? user.rxlabUserID : nil), + optionalQuery("item", item), + optionalQuery("groupBy", groupByItem ? "item" : nil), + ].compactMap { $0 } + ) + } + + public func consumptionStatistics( + from: Date, + to: Date, + granularity: SeriesGranularity = .day, + unit: String? = nil, + groupBy: ConsumptionGrouping? = nil, + forCurrentUser: Bool = true + ) async throws -> ConsumptionSeries { + try await get( + "api/v1/balances/consumption", + query: seriesQuery(from: from, to: to, granularity: granularity) + [ + optionalQuery("rxlabUserId", forCurrentUser ? user.rxlabUserID : nil), + optionalQuery("unit", unit), + optionalQuery("groupBy", groupBy?.rawValue), + ].compactMap { $0 } + ) + } + + // MARK: Stripe checkout, coupons, and history + + public func checkoutPlan( + id: String, + couponCode: String? = nil, + successURL: URL? = nil, + cancelURL: URL? = nil + ) async throws -> CheckoutSession { + try await checkout( + kind: .plan, + planID: id, + couponCode: couponCode, + successURL: successURL, + cancelURL: cancelURL + ) + } + + public func checkoutTopUp( + id: String, + couponCode: String? = nil, + successURL: URL? = nil, + cancelURL: URL? = nil + ) async throws -> CheckoutSession { + try await checkout( + kind: .topup, + topupID: id, + couponCode: couponCode, + successURL: successURL, + cancelURL: cancelURL + ) + } + + public func billingPortal(returnURL: URL? = nil) async throws -> BillingPortalSession { + try await send( + "POST", + path: "api/v1/checkout", + body: CheckoutBody( + user: user, + kind: .portal, + planID: nil, + topupID: nil, + couponCode: nil, + successURL: nil, + cancelURL: nil, + returnURL: returnURL + ) + ) + } + + public func validateCoupon( + code: String, + planID: String? = nil, + topupID: String? = nil + ) async throws -> CouponValidation { + try await send( + "POST", + path: "api/v1/coupons/validate", + body: CouponBody( + user: user, + code: code, + planID: planID, + topupID: topupID + ) + ) + } + + public func purchases(page: Int = 1, pageSize: Int = 20) async throws -> PurchasePage { + try await get( + "api/v1/purchases", + query: [ + query("rxlabUserId", user.rxlabUserID), + query("page", page), + query("pageSize", pageSize), + ].compactMap { $0 } + ) + } + + public func invoices(after: String? = nil, before: String? = nil) async throws -> InvoicePage { + try await get( + "api/v1/invoices", + query: [ + query("rxlabUserId", user.rxlabUserID), + optionalQuery("after", after), + optionalQuery("before", before), + ].compactMap { $0 } + ) + } + + // MARK: App Store server bridge + + public func appleAccountToken() async throws -> AppleAccountToken { + try await send( + "POST", + path: "api/v1/iap/apple/account-token", + body: AppleUserBody(rxlabUserID: user.rxlabUserID) + ) + } + + public func setAppleConsumptionConsent(_ consented: Bool) async throws -> ConsumptionConsent { + try await send( + "PUT", + path: "api/v1/iap/apple/consumption-consent", + body: AppleConsentBody(rxlabUserID: user.rxlabUserID, consented: consented) + ) + } + + public func submitAppleTransaction(_ signedTransaction: String) async throws -> AppleFulfillment { + try await send( + "POST", + path: "api/v1/iap/apple/transactions", + body: AppleTransactionBody( + rxlabUserID: user.rxlabUserID, + signedTransaction: signedTransaction + ) + ) + } + + // MARK: Transport + + private func checkout( + kind: CheckoutKind, + planID: String? = nil, + topupID: String? = nil, + couponCode: String? = nil, + successURL: URL? = nil, + cancelURL: URL? = nil + ) async throws -> CheckoutSession { + try await send( + "POST", + path: "api/v1/checkout", + body: CheckoutBody( + user: user, + kind: kind, + planID: planID, + topupID: topupID, + couponCode: couponCode, + successURL: successURL, + cancelURL: cancelURL, + returnURL: nil + ) + ) + } + + private func get( + _ path: String, + query: [URLQueryItem] = [] + ) async throws -> Response { + try await request(method: "GET", path: path, query: query, body: nil) + } + + private func send( + _ method: String, + path: String, + body: Body, + acceptedStatusCodes: Set = Set(200...299) + ) async throws -> Response { + try await request( + method: method, + path: path, + query: [], + body: try encoder.encode(body), + acceptedStatusCodes: acceptedStatusCodes + ) + } + + private func request( + method: String, + path: String, + query: [URLQueryItem], + body: Data?, + acceptedStatusCodes: Set = Set(200...299) + ) async throws -> Response { + guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ClientError.invalidConfiguration("apiKey must not be empty.") + } + guard !user.rxlabUserID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ClientError.invalidConfiguration("rxlabUserID must not be empty.") + } + guard var components = URLComponents( + url: url(for: path), + resolvingAgainstBaseURL: false + ) else { + throw ClientError.invalidURL + } + if !query.isEmpty { components.queryItems = query } + guard let url = components.url else { throw ClientError.invalidURL } + + var request = URLRequest(url: url) + request.httpMethod = method + request.httpBody = body + request.timeoutInterval = 30 + request.setValue(apiKey, forHTTPHeaderField: "X-Api-Key") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if body != nil { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw ClientError.invalidResponse + } + guard acceptedStatusCodes.contains(http.statusCode) else { + throw ClientError.server( + statusCode: http.statusCode, + payload: try? decoder.decode(APIErrorPayload.self, from: data), + responseBody: String(data: data, encoding: .utf8) + ) + } + do { + return try decoder.decode(Response.self, from: data) + } catch { + throw error + } + } + + private func url(for path: String) -> URL { + path.split(separator: "/").reduce(serverURL) { url, component in + url.appendingPathComponent(String(component), isDirectory: false) + } + } + + private func pathComponent(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? value + } + + private func userQuery(includeProfile: Bool) -> [URLQueryItem] { + [ + query("rxlabUserId", user.rxlabUserID), + optionalQuery("email", includeProfile ? user.email : nil), + ].compactMap { $0 } + } + + private func seriesQuery( + from: Date, + to: Date, + granularity: SeriesGranularity + ) -> [URLQueryItem] { + [ + query("from", Self.apiDateFormatter.string(from: from)), + query("to", Self.apiDateFormatter.string(from: to)), + query("granularity", granularity.rawValue), + ].compactMap { $0 } + } + + private func query(_ name: String, _ value: String) -> URLQueryItem { + URLQueryItem(name: name, value: value) + } + + private func optionalQuery(_ name: String, _ value: String?) -> URLQueryItem? { + value.map { URLQueryItem(name: name, value: $0) } + } + + private func query(_ name: String, _ value: Int) -> URLQueryItem { + URLQueryItem(name: name, value: String(value)) + } + + private static let apiDateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static func makeDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + if let date = parseAPIDate(value) { + return date + } + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid ISO 8601 date: \(value)" + ) + } + return decoder + } + + nonisolated private static func parseAPIDate(_ value: String) -> Date? { + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractional.date(from: value) { return date } + + let standard = ISO8601DateFormatter() + standard.formatOptions = [.withInternetDateTime] + return standard.date(from: value) + } +} + +// MARK: - Wire request bodies + +private struct BalanceMutationBody: Encodable { + let rxlabUserID: String + let unit: String + let amount: Int + let operation: BalanceOperation + let description: String + let idempotencyKey: String + let metadata: [String: JSONValue]? + + enum CodingKeys: String, CodingKey { + case unit, amount, operation, description, idempotencyKey, metadata + case rxlabUserID = "rxlabUserId" + } +} + +private struct ReserveBalanceBody: Encodable { + let rxlabUserID: String + let unit: String + let amount: Int + let idempotencyKey: String + let description: String + let metadata: [String: JSONValue]? + let expiresInSeconds: Int + + enum CodingKeys: String, CodingKey { + case unit, amount, idempotencyKey, description, metadata, expiresInSeconds + case rxlabUserID = "rxlabUserId" + } +} + +private struct IncreaseReservationBody: Encodable { + let amount: Int + let idempotencyKey: String +} + +private struct SettleReservationBody: Encodable { + let amount: Int + let idempotencyKey: String + let final: Bool + let description: String? + let metadata: [String: JSONValue]? +} + +private struct ReleaseReservationBody: Encodable { + let idempotencyKey: String + let reason: String? +} + +private struct RecordUsageBody: Encodable { + let rxlabUserID: String + let item: String + let amount: Int + let idempotencyKey: String? + let metadata: [String: JSONValue]? + + enum CodingKeys: String, CodingKey { + case item, amount, idempotencyKey, metadata + case rxlabUserID = "rxlabUserId" + } +} + +private struct CheckoutBody: Encodable { + let rxlabUserID: String + let email: String? + let displayName: String? + let kind: CheckoutKind + let planID: String? + let topupID: String? + let couponCode: String? + let successURL: URL? + let cancelURL: URL? + let returnURL: URL? + + init( + user: UserIdentity, + kind: CheckoutKind, + planID: String?, + topupID: String?, + couponCode: String?, + successURL: URL?, + cancelURL: URL?, + returnURL: URL? + ) { + rxlabUserID = user.rxlabUserID + email = user.email + displayName = user.displayName + self.kind = kind + self.planID = planID + self.topupID = topupID + self.couponCode = couponCode + self.successURL = successURL + self.cancelURL = cancelURL + self.returnURL = returnURL + } + + enum CodingKeys: String, CodingKey { + case email, displayName, kind, couponCode + case rxlabUserID = "rxlabUserId" + case planID = "planId" + case topupID = "topupId" + case successURL = "successUrl" + case cancelURL = "cancelUrl" + case returnURL = "returnUrl" + } +} + +private struct CouponBody: Encodable { + let rxlabUserID: String + let email: String? + let displayName: String? + let code: String + let planID: String? + let topupID: String? + + init(user: UserIdentity, code: String, planID: String?, topupID: String?) { + rxlabUserID = user.rxlabUserID + email = user.email + displayName = user.displayName + self.code = code + self.planID = planID + self.topupID = topupID + } + + enum CodingKeys: String, CodingKey { + case email, displayName, code + case rxlabUserID = "rxlabUserId" + case planID = "planId" + case topupID = "topupId" + } +} + +private struct AppleUserBody: Encodable { + let rxlabUserID: String + enum CodingKeys: String, CodingKey { case rxlabUserID = "rxlabUserId" } +} + +private struct AppleConsentBody: Encodable { + let rxlabUserID: String + let consented: Bool + enum CodingKeys: String, CodingKey { + case consented + case rxlabUserID = "rxlabUserId" + } +} + +private struct AppleTransactionBody: Encodable { + let rxlabUserID: String + let signedTransaction: String + enum CodingKeys: String, CodingKey { + case signedTransaction + case rxlabUserID = "rxlabUserId" + } +} diff --git a/Sources/RxSubscriptionIOS/Models.swift b/Sources/RxSubscriptionIOS/Models.swift new file mode 100644 index 0000000..550a4f5 --- /dev/null +++ b/Sources/RxSubscriptionIOS/Models.swift @@ -0,0 +1,694 @@ +import Foundation + +// MARK: - Common values + +/// A Codable representation of arbitrary JSON used by metadata fields. +public enum JSONValue: Codable, Hashable, Sendable { + case string(String) + case number(Double) + case bool(Bool) + case object([String: JSONValue]) + case array([JSONValue]) + case null + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([String: JSONValue].self) { + self = .object(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unsupported JSON value" + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): try container.encode(value) + case .number(let value): try container.encode(value) + case .bool(let value): try container.encode(value) + case .object(let value): try container.encode(value) + case .array(let value): try container.encode(value) + case .null: try container.encodeNil() + } + } +} + +public enum BillingProvider: String, Codable, CaseIterable, Sendable { + case stripe + case appleAppStore = "apple_app_store" + /// Reserved by the backend contract. No Google purchase transport is exposed yet. + case googlePlay = "google_play" +} + +public enum PurchaseFlow: String, Codable, Sendable { + case checkout + case storeKit = "storekit" +} + +public enum StoreProductType: String, Codable, Sendable { + case autoRenewableSubscription = "auto_renewable_subscription" + case nonConsumable = "non_consumable" + case consumable +} + +public struct APIErrorPayload: Codable, Hashable, Sendable { + public let error: String + public let errorDescription: String? + public let available: Int? + public let required: Int? + public let status: String? + public let blockers: [JSONValue]? + public let blockedBy: [JSONValue]? + + enum CodingKeys: String, CodingKey { + case error + case errorDescription = "error_description" + case available, required, status, blockers, blockedBy + } +} + +// MARK: - Catalog + +public struct PurchaseOption: Codable, Hashable, Sendable, Identifiable { + public let provider: BillingProvider + public let flow: PurchaseFlow + public let productID: String? + public let productType: StoreProductType? + + public var id: String { + [provider.rawValue, flow.rawValue, productID ?? "default"].joined(separator: ":") + } + + enum CodingKeys: String, CodingKey { + case provider, flow + case productID = "productId" + case productType + } +} + +public struct SubscriptionPlan: Codable, Hashable, Sendable, Identifiable { + public let id: String + public let key: String + public let name: String + public let description: String? + public let planGroup: String + public let billingInterval: String + public let intervalCount: Int + public let priceAmountCents: Int + public let currency: String + public let trialDays: Int + public let purchaseOptions: [PurchaseOption] +} + +public struct TopUpEligibilityBlocker: Codable, Hashable, Sendable { + public let ruleType: String + public let planID: String? + public let roleID: String? + + enum CodingKeys: String, CodingKey { + case ruleType + case planID = "planId" + case roleID = "roleId" + } +} + +public struct TopUpProduct: Codable, Hashable, Sendable, Identifiable { + public let id: String + public let key: String + public let name: String + public let description: String? + public let unit: String? + public let amount: Int + public let priceAmountCents: Int + public let currency: String + public let eligible: Bool? + public let blockedBy: [TopUpEligibilityBlocker]? + public let purchaseOptions: [PurchaseOption] +} + +public struct Catalog: Codable, Hashable, Sendable { + public let plans: [SubscriptionPlan] + public let topups: [TopUpProduct] +} + +// MARK: - Entitlements, balances, and usage + +public struct SubscriptionUser: Codable, Hashable, Sendable, Identifiable { + public let id: String + public let rxlabUserID: String + public let level: Int + public let levelKey: String? + + enum CodingKeys: String, CodingKey { + case id, level, levelKey + case rxlabUserID = "rxlabUserId" + } +} + +public struct EntitledPlan: Codable, Hashable, Sendable, Identifiable { + public let subscriptionID: String? + public let purchaseID: String? + public let planID: String + public let planKey: String + public let planName: String + public let planGroup: String + public let status: String + public let currentPeriodStart: Date? + public let currentPeriodEnd: Date? + public let cancelAtPeriodEnd: Bool + public let billingProvider: BillingProvider + public let providerProductID: String? + + public var id: String { subscriptionID ?? purchaseID ?? planID } + + enum CodingKeys: String, CodingKey { + case status, planKey, planName, planGroup, currentPeriodStart, currentPeriodEnd + case cancelAtPeriodEnd, billingProvider + case subscriptionID = "subscriptionId" + case purchaseID = "purchaseId" + case planID = "planId" + case providerProductID = "providerProductId" + } +} + +public struct Balance: Codable, Hashable, Sendable, Identifiable { + public let unit: String + public let name: String + public let symbol: String? + public let precision: Int + public let amount: Int + public let available: Int + + public var id: String { unit } +} + +public struct UsageStatus: Codable, Hashable, Sendable, Identifiable { + public let itemID: String? + public let key: String + public let name: String + public let used: Int + public let limit: Int? + public let remaining: Int? + public let periodStart: Date? + public let periodEnd: Date? + public let resetsAt: Date? + public let resetPolicy: String + public let overagePolicy: String? + + public var id: String { itemID ?? key } + + enum CodingKeys: String, CodingKey { + case key, name, used, limit, remaining, periodStart, periodEnd, resetsAt + case resetPolicy, overagePolicy + case itemID = "itemId" + } +} + +public struct Entitlements: Codable, Hashable, Sendable { + public let user: SubscriptionUser + public let plans: [EntitledPlan] + public let roles: [String] + public let permissions: [String] + public let features: [String: String?] + public let balances: [Balance] + public let usage: [UsageStatus] +} + +public struct BalancesResponse: Codable, Hashable, Sendable { + public let balances: [Balance] +} + +public enum BalanceOperation: String, Codable, Sendable { + case credit + case debit +} + +public struct BalanceMutationResult: Codable, Hashable, Sendable { + public let entryID: String + public let duplicate: Bool + public let balanceAfter: Int + + enum CodingKeys: String, CodingKey { + case duplicate, balanceAfter + case entryID = "entryId" + } +} + +public struct LedgerEntry: Codable, Hashable, Sendable, Identifiable { + public let id: String + public let kind: String + public let unit: String + public let delta: Int + public let balanceAfter: Int + public let description: String + public let referenceType: String? + public let referenceID: String? + public let createdAt: Date + public let metadata: [String: JSONValue]? + + enum CodingKeys: String, CodingKey { + case id, kind, unit, delta, balanceAfter, description, referenceType, createdAt, metadata + case referenceID = "referenceId" + } +} + +public struct LedgerPage: Codable, Hashable, Sendable { + public let entries: [LedgerEntry] + public let total: Int + public let page: Int + public let pageSize: Int + public let pageCount: Int +} + +public struct UsageResponse: Codable, Hashable, Sendable { + public let usage: [UsageStatus] +} + +public struct UsageRecordResult: Codable, Hashable, Sendable { + public let allowed: Bool + public let reason: String? + public let used: Int + public let limit: Int? + public let remaining: Int? + public let chargedUnits: Int + public let periodEnd: Date? + public let duplicate: Bool +} + +// MARK: - Time series + +public enum SeriesGranularity: String, Codable, CaseIterable, Sendable { + case minute, hour, day, week, month +} + +public enum ConsumptionGrouping: String, Codable, Sendable { + case kind + case description +} + +public struct ConsumptionBucket: Codable, Hashable, Sendable, Identifiable { + public let start: Date + public let spent: Int + public let granted: Int + public let net: Int + public let entryCount: Int + + public var id: Date { start } +} + +public struct ConsumptionGroup: Codable, Hashable, Sendable, Identifiable { + public let key: String + public let label: String + public let spent: Int + public let granted: Int + public let net: Int + public let entryCount: Int + public let buckets: [ConsumptionBucket] + + public var id: String { key } +} + +public struct BalanceUnitSummary: Codable, Hashable, Sendable { + public let key: String + public let name: String + public let symbol: String? + public let precision: Int +} + +public struct ConsumptionSeries: Codable, Hashable, Sendable { + public let from: Date + public let to: Date + public let granularity: SeriesGranularity + public let totals: [ConsumptionBucket] + public let groups: [ConsumptionGroup]? + public let unit: BalanceUnitSummary? +} + +public struct UsageBucket: Codable, Hashable, Sendable, Identifiable { + public let start: Date + public let amount: Int + public let consumed: Int + public let eventCount: Int + public let chargedUnits: Int + + public var id: Date { start } +} + +public struct UsageGroup: Codable, Hashable, Sendable, Identifiable { + public let key: String + public let label: String + public let amount: Int + public let consumed: Int + public let eventCount: Int + public let chargedUnits: Int + public let buckets: [UsageBucket] + + public var id: String { key } +} + +public struct UsageSeries: Codable, Hashable, Sendable { + public let from: Date + public let to: Date + public let granularity: SeriesGranularity + public let totals: [UsageBucket] + public let groups: [UsageGroup]? +} + +// MARK: - Reservations + +public struct BalanceReservationResult: Codable, Hashable, Sendable { + public let reservationID: String + public let amount: Int + public let available: Int + public let expiresAt: Date + public let status: String? + public let duplicate: Bool + + enum CodingKeys: String, CodingKey { + case amount, available, expiresAt, status, duplicate + case reservationID = "reservationId" + } +} + +public struct BalanceReservation: Codable, Hashable, Sendable, Identifiable { + public let reservationID: String + public let rxlabUserID: String + public let unit: String + public let initialAmount: Int + public let remainingReserved: Int + public let status: String + public let description: String + public let metadata: [String: JSONValue]? + public let requestedAmount: Int + public let settledAmount: Int + public let shortfallAmount: Int + public let releasedAmount: Int + public let available: Int + public let balanceAfter: Int? + public let expiresAt: Date + public let releaseReason: String? + public let entryID: String? + public let createdAt: Date + public let updatedAt: Date + public let closedAt: Date? + + public var id: String { reservationID } + + enum CodingKeys: String, CodingKey { + case unit, initialAmount, remainingReserved, status, description, metadata + case requestedAmount, settledAmount, shortfallAmount, releasedAmount, available + case balanceAfter, expiresAt, releaseReason, createdAt, updatedAt, closedAt + case reservationID = "reservationId" + case rxlabUserID = "rxlabUserId" + case entryID = "entryId" + } +} + +public struct BalanceReservationResponse: Codable, Hashable, Sendable { + public let reservation: BalanceReservation +} + +public struct ReservationSettlement: Codable, Hashable, Sendable { + public let reservationID: String + public let entryID: String + public let operationRequestedAmount: Int + public let operationSettledAmount: Int + public let operationShortfallAmount: Int + public let requestedAmount: Int + public let settledAmount: Int + public let shortfallAmount: Int + public let remainingReserved: Int + public let balanceAfter: Int + public let status: String + public let expiresAt: Date? + public let duplicate: Bool + + enum CodingKeys: String, CodingKey { + case operationRequestedAmount, operationSettledAmount, operationShortfallAmount + case requestedAmount, settledAmount, shortfallAmount, remainingReserved + case balanceAfter, status, expiresAt, duplicate + case reservationID = "reservationId" + case entryID = "entryId" + } +} + +public struct ReservationRelease: Codable, Hashable, Sendable { + public let reservationID: String + public let released: Bool + public let releasedAmount: Int + public let remainingReserved: Int + public let balanceAfter: Int + public let status: String + public let duplicate: Bool + + enum CodingKeys: String, CodingKey { + case released, releasedAmount, remainingReserved, balanceAfter, status, duplicate + case reservationID = "reservationId" + } +} + +// MARK: - Checkout and history + +public enum CheckoutKind: String, Codable, Sendable { + case plan + case topup + case portal +} + +public struct CheckoutDiscount: Codable, Hashable, Sendable { + public let code: String + public let discountCents: Int +} + +public struct CheckoutSession: Codable, Hashable, Sendable { + public let checkoutURL: URL + public let sessionID: String + public let purchaseID: String? + public let discount: CheckoutDiscount? + public let promotionCodesEnabled: Bool + + enum CodingKeys: String, CodingKey { + case discount, promotionCodesEnabled + case checkoutURL = "checkoutUrl" + case sessionID = "sessionId" + case purchaseID = "purchaseId" + } +} + +public struct BillingPortalSession: Codable, Hashable, Sendable { + public let url: URL +} + +public struct CouponValidation: Codable, Hashable, Sendable { + public let valid: Bool + public let code: String? + public let name: String? + public let description: String? + public let terms: String? + public let duration: String? + public let durationInMonths: Int? + public let discountCents: Int? + public let totalCents: Int? + public let currency: String? + public let capped: Bool? + public let reason: String? + public let blockers: [String] +} + +public struct PurchaseRecord: Codable, Hashable, Sendable, Identifiable { + public let id: String + public let applicationID: String? + public let appUserID: String? + public let kind: String + public let planID: String? + public let topupProductID: String? + public let unitID: String? + public let unit: String? + public let unitsGranted: Int + public let amountCents: Int + public let currency: String + public let status: String + public let billingProvider: BillingProvider + public let providerTransactionID: String? + public let providerOriginalTransactionID: String? + public let providerProductID: String? + public let quantity: Int + public let priceMilliunits: Int? + public let entitlementSnapshot: [String: JSONValue]? + public let fulfillmentFailureCode: String? + public let stripeCheckoutSessionID: String? + public let stripePaymentIntentID: String? + public let stripeInvoiceID: String? + public let hostedInvoiceURL: URL? + public let invoicePDFURL: URL? + public let refundedAmountCents: Int? + public let reversedUnits: Int? + public let createdAt: Date + public let updatedAt: Date? + public let paidAt: Date? + + enum CodingKeys: String, CodingKey { + case id, kind, unit, unitsGranted, amountCents, currency, status, billingProvider + case quantity, priceMilliunits, entitlementSnapshot, fulfillmentFailureCode + case refundedAmountCents, reversedUnits, createdAt, updatedAt, paidAt + case applicationID = "applicationId" + case appUserID = "appUserId" + case planID = "planId" + case topupProductID = "topupProductId" + case unitID = "unitId" + case providerTransactionID = "providerTransactionId" + case providerOriginalTransactionID = "providerOriginalTransactionId" + case providerProductID = "providerProductId" + case stripeCheckoutSessionID = "stripeCheckoutSessionId" + case stripePaymentIntentID = "stripePaymentIntentId" + case stripeInvoiceID = "stripeInvoiceId" + case hostedInvoiceURL = "hostedInvoiceUrl" + case invoicePDFURL = "invoicePdfUrl" + } +} + +public struct PurchasePage: Codable, Hashable, Sendable { + public let purchases: [PurchaseRecord] + public let total: Int + public let page: Int + public let pageSize: Int + public let pageCount: Int +} + +public struct Invoice: Codable, Hashable, Sendable, Identifiable { + public let id: String + public let number: String? + public let description: String + public let status: String + public let amountCents: Int + public let currency: String + public let createdAt: Date + public let hostedInvoiceURL: URL? + public let invoicePDFURL: URL? + + enum CodingKeys: String, CodingKey { + case id, number, description, status, amountCents, currency, createdAt + case hostedInvoiceURL = "hostedInvoiceUrl" + case invoicePDFURL = "invoicePdfUrl" + } +} + +public struct CursorPagination: Codable, Hashable, Sendable { + public let hasMore: Bool + public let firstCursor: String? + public let lastCursor: String? +} + +public struct InvoicePage: Codable, Hashable, Sendable { + public let invoices: [Invoice] + public let pagination: CursorPagination +} + +// MARK: - App Store + +public struct AppleAccountToken: Codable, Hashable, Sendable { + public let appAccountToken: UUID + public let environment: String +} + +public struct ConsumptionConsent: Codable, Hashable, Sendable { + public let consented: Bool + public let updatedAt: Date? +} + +public struct AppleTransaction: Codable, Hashable, Sendable { + public let transactionID: String + public let originalTransactionID: String + public let productID: String + public let productType: StoreProductType + public let environment: String + public let quantity: Int + public let priceMilliunits: Int? + public let currency: String? + public let purchaseAt: Date + public let expiresAt: Date? + public let revokedAt: Date? + + enum CodingKeys: String, CodingKey { + case productType, environment, quantity, priceMilliunits, currency + case purchaseAt, expiresAt, revokedAt + case transactionID = "transactionId" + case originalTransactionID = "originalTransactionId" + case productID = "productId" + } +} + +public struct SubscriptionRecord: Codable, Hashable, Sendable, Identifiable { + public let id: String + public let applicationID: String + public let appUserID: String + public let planID: String + public let status: String + public let currentPeriodStart: Date? + public let currentPeriodEnd: Date? + public let cancelAtPeriodEnd: Bool + public let billingProvider: BillingProvider + public let providerSubscriptionID: String? + public let providerProductID: String? + public let providerSignedAt: Date? + public let stripeSubscriptionID: String? + public let stripeCustomerID: String? + public let entitlementSnapshot: [String: JSONValue]? + public let startedAt: Date + public let endedAt: Date? + public let createdAt: Date + public let updatedAt: Date + + enum CodingKeys: String, CodingKey { + case id, status, currentPeriodStart, currentPeriodEnd, cancelAtPeriodEnd + case billingProvider, providerSignedAt, entitlementSnapshot, startedAt + case endedAt, createdAt, updatedAt + case applicationID = "applicationId" + case appUserID = "appUserId" + case planID = "planId" + case providerSubscriptionID = "providerSubscriptionId" + case providerProductID = "providerProductId" + case stripeSubscriptionID = "stripeSubscriptionId" + case stripeCustomerID = "stripeCustomerId" + } +} + +public struct AppleFulfillment: Codable, Hashable, Sendable { + public let processed: String + public let transaction: AppleTransaction + public let purchase: PurchaseRecord? + public let subscription: SubscriptionRecord? +} + +public enum StorePurchaseOutcome: Hashable, Sendable { + case completed(AppleFulfillment) + case pending + case cancelled +} + +public struct StoreProductInfo: Hashable, Sendable, Identifiable { + public let id: String + public let displayName: String + public let description: String + public let displayPrice: String + + public init(id: String, displayName: String, description: String, displayPrice: String) { + self.id = id + self.displayName = displayName + self.description = description + self.displayPrice = displayPrice + } +} diff --git a/Sources/RxSubscriptionIOS/RxSubscriptionIOS.swift b/Sources/RxSubscriptionIOS/RxSubscriptionIOS.swift new file mode 100644 index 0000000..b12dd02 --- /dev/null +++ b/Sources/RxSubscriptionIOS/RxSubscriptionIOS.swift @@ -0,0 +1,5 @@ +/// RxSubscriptionIOS provides a typed backend client, StoreKit 2 fulfillment, +/// and ready-to-use SwiftUI subscription, top-up, usage, and balance views. +public enum RxSubscriptionIOS { + public static let version = "0.1.0" +} diff --git a/Sources/RxSubscriptionIOS/StoreKitSupport.swift b/Sources/RxSubscriptionIOS/StoreKitSupport.swift new file mode 100644 index 0000000..0e35942 --- /dev/null +++ b/Sources/RxSubscriptionIOS/StoreKitSupport.swift @@ -0,0 +1,58 @@ +import Foundation +import StoreKit + +@MainActor +public extension Client { + /// Loads localized App Store product information for the mapped product IDs. + func storeProducts(productIDs: [String]) async throws -> [StoreProductInfo] { + let products = try await Product.products(for: Array(Set(productIDs))) + return products.map { + StoreProductInfo( + id: $0.id, + displayName: $0.displayName, + description: $0.description, + displayPrice: $0.displayPrice + ) + } + } + + /// Purchases one mapped StoreKit product, waits for server fulfillment, then finishes it. + func purchaseApple(productID: String, quantity: Int = 1) async throws -> StorePurchaseOutcome { + guard let product = try await Product.products(for: [productID]).first else { + throw ClientError.storeProductNotFound(productID) + } + let account = try await appleAccountToken() + var options: Set = [.appAccountToken(account.appAccountToken)] + if quantity > 1 { options.insert(.quantity(quantity)) } + + switch try await product.purchase(options: options) { + case .success(let verification): + guard case .verified(let transaction) = verification else { + throw ClientError.unverifiedStoreTransaction + } + let fulfillment = try await submitAppleTransaction(verification.jwsRepresentation) + await transaction.finish() + return .completed(fulfillment) + case .pending: + return .pending + case .userCancelled: + return .cancelled + @unknown default: + return .pending + } + } + + /// Presents Apple's restore sheet, reconciles every current entitlement, and finishes it. + @discardableResult + func restoreApplePurchases() async throws -> [AppleFulfillment] { + try await AppStore.sync() + var restored: [AppleFulfillment] = [] + for await verification in Transaction.currentEntitlements { + guard case .verified(let transaction) = verification else { continue } + let fulfillment = try await submitAppleTransaction(verification.jwsRepresentation) + await transaction.finish() + restored.append(fulfillment) + } + return restored + } +} diff --git a/Sources/RxSubscriptionIOS/Views/BalanceHistoryView.swift b/Sources/RxSubscriptionIOS/Views/BalanceHistoryView.swift new file mode 100644 index 0000000..252685a --- /dev/null +++ b/Sources/RxSubscriptionIOS/Views/BalanceHistoryView.swift @@ -0,0 +1,146 @@ +import SwiftUI + +public struct BalanceHistoryView: View { + @StateObject private var model: BalanceHistoryViewModel + + public init(client: Client, unit: String? = nil) { + _model = StateObject(wrappedValue: BalanceHistoryViewModel(client: client, unit: unit)) + } + + public var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 12, pinnedViews: .sectionHeaders) { + if model.isLoading && model.entries.isEmpty { + LoadingView(title: "Loading history…") + } else if let error = model.error, model.entries.isEmpty { + InlineErrorView(message: error) { Task { await model.reload() } } + } else if model.entries.isEmpty { + EmptyStateView( + icon: "clock.arrow.circlepath", + title: "No balance history", + message: "Credits and usage will appear here." + ) + } else { + ForEach(groupedEntries, id: \.label) { group in + Section { + VStack(spacing: 0) { + ForEach(Array(group.entries.enumerated()), id: \.element.id) { index, entry in + BalanceHistoryRow(entry: entry, showTime: true) + .padding(.horizontal, 18) + if index < group.entries.count - 1 { + Divider().padding(.leading, 74) + } + } + } + .cardGlass(cornerRadius: 20) + } header: { + Text(group.label) + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .tracking(0.6) + .padding(.horizontal, 4) + .padding(.top, 8) + .padding(.bottom, 2) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + if model.hasMore { + GlassButton( + model.isLoading ? "Loading…" : "Load More", + isDisabled: model.isLoading + ) { + Task { await model.loadNextPage() } + } + .frame(maxWidth: .infinity) + .padding(.top, 4) + } + } + } + .padding(20) + } + .refreshable { await model.reload() } + .task { if model.entries.isEmpty { await model.reload() } } + } + + private var groupedEntries: [(label: String, entries: [LedgerEntry])] { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + let yesterday = calendar.date(byAdding: .day, value: -1, to: today)! + + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + + var result: [(label: String, entries: [LedgerEntry])] = [] + var labelToIndex: [String: Int] = [:] + + for entry in model.entries { + let day = calendar.startOfDay(for: entry.createdAt) + let label: String + if day == today { + label = "Today" + } else if day == yesterday { + label = "Yesterday" + } else { + label = formatter.string(from: day) + } + + if let idx = labelToIndex[label] { + result[idx].entries.append(entry) + } else { + labelToIndex[label] = result.count + result.append((label: label, entries: [entry])) + } + } + return result + } +} + +#Preview("Balance History") { + NavigationStack { + BalanceHistoryView(client: PreviewFixtures.client()) + .navigationTitle("Balance History") + } +} + +@MainActor +private final class BalanceHistoryViewModel: ObservableObject { + @Published var entries: [LedgerEntry] = [] + @Published var isLoading = false + @Published var error: String? + + let client: Client + let unit: String? + private var page = 0 + private var pageCount = 1 + var hasMore: Bool { page < pageCount } + + init(client: Client, unit: String?) { + self.client = client + self.unit = unit + } + + func reload() async { + page = 0 + pageCount = 1 + entries = [] + await loadNextPage() + } + + func loadNextPage() async { + guard !isLoading, page < pageCount else { return } + isLoading = true + error = nil + defer { isLoading = false } + do { + let next = try await client.ledger(unit: unit, page: page + 1) + entries.append(contentsOf: next.entries) + page = next.page + pageCount = next.pageCount + } catch { + self.error = error.localizedDescription + } + } +} diff --git a/Sources/RxSubscriptionIOS/Views/BalanceView.swift b/Sources/RxSubscriptionIOS/Views/BalanceView.swift new file mode 100644 index 0000000..fac28bc --- /dev/null +++ b/Sources/RxSubscriptionIOS/Views/BalanceView.swift @@ -0,0 +1,60 @@ +import SwiftUI + +public struct BalanceView: View { + @StateObject private var model: BalanceViewModel + + public init(client: Client) { + _model = StateObject(wrappedValue: BalanceViewModel(client: client)) + } + + public var body: some View { + ScrollView { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 145), spacing: 12)], + spacing: 12 + ) { + if model.isLoading && model.balances.isEmpty { + LoadingView(title: "Loading balances…") + } else if let error = model.error, model.balances.isEmpty { + InlineErrorView(message: error) { Task { await model.load() } } + } else if model.balances.isEmpty { + EmptyStateView( + icon: "creditcard", + title: "No balances", + message: "Available units will appear here." + ) + } else { + ForEach(model.balances) { BalanceCard(balance: $0) } + } + } + .padding(20) + } + .refreshable { await model.load() } + .task { if model.balances.isEmpty { await model.load() } } + } +} + +#Preview("Balances") { + NavigationStack { + BalanceView(client: PreviewFixtures.client()) + .navigationTitle("Balances") + } +} + +@MainActor +private final class BalanceViewModel: ObservableObject { + @Published var balances: [Balance] = [] + @Published var isLoading = false + @Published var error: String? + + let client: Client + init(client: Client) { self.client = client } + + func load() async { + isLoading = true + error = nil + defer { isLoading = false } + do { balances = try await client.balances() } + catch { self.error = error.localizedDescription } + } +} diff --git a/Sources/RxSubscriptionIOS/Views/Components.swift b/Sources/RxSubscriptionIOS/Views/Components.swift new file mode 100644 index 0000000..1587dbd --- /dev/null +++ b/Sources/RxSubscriptionIOS/Views/Components.swift @@ -0,0 +1,559 @@ +import SwiftUI + +// MARK: - Formatting + +public enum SubscriptionFormatting { + public static func price(cents: Int, currency: String) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = currency.uppercased() + return formatter.string(from: NSNumber(value: Double(cents) / 100)) + ?? "\(currency.uppercased()) \(Double(cents) / 100)" + } + + public static func balance(_ amount: Int, precision: Int, symbol: String? = nil) -> String { + let divisor = pow(10.0, Double(precision)) + let value = Double(amount) / divisor + let formatter = NumberFormatter() + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = max(0, precision) + formatter.numberStyle = .decimal + let number = formatter.string(from: NSNumber(value: value)) ?? String(value) + return [symbol, number].compactMap { $0 }.joined(separator: symbol == nil ? "" : " ") + } +} + +// MARK: - Design Helpers (Liquid Glass on iOS 26+, material fallback on older) + +extension View { + /// Card background: Liquid Glass on iOS 26+, regularMaterial otherwise. + @ViewBuilder + func cardGlass(cornerRadius: CGFloat = 18) -> some View { + if #available(iOS 26, *) { + self.glassEffect(in: .rect(cornerRadius: cornerRadius)) + } else { + self + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .strokeBorder(.quaternary, lineWidth: 0.5) + } + } + } + + /// Capsule badge with tinted glass on iOS 26+, tinted opacity fill otherwise. + @ViewBuilder + func tintedCapsule(_ color: Color) -> some View { + if #available(iOS 26, *) { + self.glassEffect(.regular.tint(color), in: .capsule) + } else { + self.background(color.opacity(0.15), in: Capsule()) + } + } + + /// Rounded-rect badge with tinted glass on iOS 26+, tinted opacity fill otherwise. + @ViewBuilder + func tintedRect(_ color: Color, cornerRadius: CGFloat = 12) -> some View { + if #available(iOS 26, *) { + self.glassEffect(.regular.tint(color), in: .rect(cornerRadius: cornerRadius)) + } else { + self.background( + color.opacity(0.12), + in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + ) + } + } +} + +/// Secondary-action button: Glass style on iOS 26+, bordered otherwise. +struct GlassButton: View { + let title: String + let isDisabled: Bool + let action: () -> Void + + init(_ title: String, isDisabled: Bool = false, action: @escaping () -> Void) { + self.title = title + self.isDisabled = isDisabled + self.action = action + } + + var body: some View { + if #available(iOS 26, *) { + Button(title, action: action) + .buttonStyle(.glass) + .disabled(isDisabled) + } else { + Button(title, action: action) + .buttonStyle(.bordered) + .disabled(isDisabled) + } + } +} + +// MARK: - Subscription Plan Card + +public struct SubscriptionPlanCard: View { + public let plan: SubscriptionPlan + public let price: String + public let actionTitle: String + public let isLoading: Bool + public let action: () -> Void + + public init( + plan: SubscriptionPlan, + price: String, + actionTitle: String, + isLoading: Bool = false, + action: @escaping () -> Void + ) { + self.plan = plan + self.price = price + self.actionTitle = actionTitle + self.isLoading = isLoading + self.action = action + } + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Info section + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(plan.name) + .font(.title3.weight(.bold)) + Text(intervalText) + .font(.footnote) + .foregroundStyle(.secondary) + } + Spacer(minLength: 8) + VStack(alignment: .trailing, spacing: 2) { + Text(price) + .font(.title2.weight(.bold).monospacedDigit()) + if plan.billingInterval != "one_time" { + Text("/ \(plan.billingInterval)") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + + if let description = plan.description, !description.isEmpty { + Text(description) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + if plan.trialDays > 0 { + Label("\(plan.trialDays)-day free trial", systemImage: "gift.fill") + .font(.footnote.weight(.semibold)) + .foregroundStyle(Color.white) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.accentColor, in: Capsule()) + } + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 16) + + Divider() + + // Action section + Button(action: action) { + Group { + if isLoading { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Processing…") + } + } else { + Text(actionTitle) + } + } + .frame(maxWidth: .infinity) + .font(.body.weight(.semibold)) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(isLoading) + .padding(.horizontal, 16) + .padding(.vertical, 14) + } + .cardGlass(cornerRadius: 20) + } + + private var intervalText: String { + if plan.billingInterval == "one_time" { return "One-time purchase" } + let count = plan.intervalCount + let interval = count == 1 ? plan.billingInterval : "\(plan.billingInterval)s" + return count == 1 ? "Billed every \(interval)" : "Billed every \(count) \(interval)" + } +} + +// MARK: - Top-up Card + +public struct TopUpCard: View { + public let topUp: TopUpProduct + public let price: String + public let isLoading: Bool + public let action: () -> Void + + public init( + topUp: TopUpProduct, + price: String, + isLoading: Bool = false, + action: @escaping () -> Void + ) { + self.topUp = topUp + self.price = price + self.isLoading = isLoading + self.action = action + } + + private var isEligible: Bool { topUp.eligible != false } + + public var body: some View { + HStack(alignment: .center, spacing: 14) { + // Amount badge + VStack(spacing: 3) { + Text(topUp.amount.formatted()) + .font(.title3.weight(.bold).monospacedDigit()) + .foregroundStyle(isEligible ? Color.accentColor : Color.secondary) + Text(topUp.unit ?? "units") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .tracking(0.3) + } + .frame(width: 70) + .padding(.vertical, 14) + .tintedRect(isEligible ? Color.accentColor : Color.gray, cornerRadius: 12) + + // Info + VStack(alignment: .leading, spacing: 4) { + Text(topUp.name) + .font(.headline) + if let description = topUp.description, !description.isEmpty { + Text(description) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + } + if !isEligible { + Label(eligibilityText, systemImage: "lock.fill") + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.top, 2) + } + } + + Spacer(minLength: 0) + + // Price + buy + VStack(alignment: .trailing, spacing: 8) { + Text(price) + .font(.subheadline.weight(.bold).monospacedDigit()) + Button(action: action) { + Group { + if isLoading { + ProgressView().controlSize(.small) + } else { + Text("Buy").font(.footnote.weight(.semibold)) + } + } + .frame(width: 52) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(isLoading || !isEligible) + } + } + .padding(16) + .cardGlass(cornerRadius: 16) + .opacity(isEligible ? 1 : 0.55) + } + + private var eligibilityText: String { + let rules = topUp.blockedBy?.map(\.ruleType) ?? [] + if rules.contains("purchase_limit") { return "Purchase limit reached" } + if rules.contains("requires_role") { return "Membership required" } + if rules.contains("requires_active_plan") { return "Specific plan required" } + if rules.contains("requires_any_plan") { return "Active plan required" } + return "Not currently eligible" + } +} + +// MARK: - Usage Item Row + +public struct UsageItemRow: View { + public let item: UsageStatus + + public init(item: UsageStatus) { + self.item = item + } + + private var progressFraction: Double { + guard let limit = item.limit, limit > 0 else { return 0 } + return min(1, Double(item.used) / Double(limit)) + } + + private var progressTint: Color { + guard let remaining = item.remaining else { return Color.accentColor } + if remaining == 0 { return .red } + if progressFraction > 0.8 { return .orange } + return Color.accentColor + } + + public var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + Text(item.name) + .font(.body.weight(.semibold)) + Spacer(minLength: 8) + if let limit = item.limit, limit > 0 { + Text(usageSummary) + .font(.subheadline.monospacedDigit()) + .foregroundStyle(.secondary) + } else { + Label("Unlimited", systemImage: "infinity") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tint) + } + } + + if let limit = item.limit, limit > 0 { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(Color.secondary.opacity(0.15)) + .frame(height: 7) + Capsule() + .fill(progressTint) + .frame(width: max(7, geo.size.width * progressFraction), height: 7) + .animation(.spring(response: 0.55, dampingFraction: 0.8), value: progressFraction) + } + } + .frame(height: 7) + } + + if let resetsAt = item.resetsAt { + Label( + "Resets \(resetsAt.formatted(date: .abbreviated, time: .omitted))", + systemImage: "arrow.clockwise" + ) + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .padding(.vertical, 14) + } + + private var usageSummary: String { + guard let limit = item.limit else { return "\(item.used.formatted()) used" } + return "\(item.used.formatted()) / \(limit.formatted())" + } +} + +// MARK: - Balance Card + +public struct BalanceCard: View { + public let balance: Balance + + public init(balance: Balance) { + self.balance = balance + } + + public var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 6) { + Image(systemName: "creditcard.fill") + .font(.caption.weight(.bold)) + .foregroundStyle(.tint) + Text(balance.name) + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .tracking(0.4) + } + + Text(SubscriptionFormatting.balance( + balance.available, + precision: balance.precision, + symbol: balance.symbol + )) + .font(.title.bold().monospacedDigit()) + .minimumScaleFactor(0.65) + .lineLimit(1) + + if balance.available != balance.amount { + let total = SubscriptionFormatting.balance(balance.amount, precision: balance.precision) + Text("\(total) total") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(18) + .cardGlass(cornerRadius: 18) + } +} + +// MARK: - Balance History Row + +public struct BalanceHistoryRow: View { + public let entry: LedgerEntry + public let showTime: Bool + + public init(entry: LedgerEntry, showTime: Bool = true) { + self.entry = entry + self.showTime = showTime + } + + private var isCredit: Bool { entry.delta >= 0 } + + private var kindIcon: String { + switch entry.kind { + case "topup": return "cart.fill" + case "plan_grant": return "gift.fill" + case "usage": return "bolt.fill" + case "adjustment": return "slider.horizontal.3" + case "refund": return "arrow.uturn.backward" + case "credit": return "plus.circle.fill" + case "debit": return "minus.circle.fill" + default: return isCredit ? "arrow.down" : "arrow.up" + } + } + + private var kindColor: Color { + switch entry.kind { + case "topup": return .green + case "plan_grant": return .indigo + case "usage": return .orange + case "adjustment": return .teal + case "refund": return .purple + default: return isCredit ? .green : .orange + } + } + + private var kindLabel: String { + switch entry.kind { + case "topup": return "Top-up" + case "plan_grant": return "Plan grant" + case "usage": return "Usage" + case "adjustment": return "Adjustment" + case "refund": return "Refund" + case "credit": return "Credit" + case "debit": return "Debit" + default: return entry.kind.replacingOccurrences(of: "_", with: " ").capitalized + } + } + + public var body: some View { + HStack(spacing: 14) { + Image(systemName: kindIcon) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 42, height: 42) + .background(kindColor, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + + VStack(alignment: .leading, spacing: 4) { + Text(entry.description) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + HStack(spacing: 5) { + Text(kindLabel) + .font(.caption2.weight(.semibold)) + .foregroundStyle(kindColor) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(kindColor.opacity(0.12), in: Capsule()) + if showTime { + Text(entry.createdAt.formatted(date: .omitted, time: .shortened)) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + + Spacer(minLength: 0) + + VStack(alignment: .trailing, spacing: 3) { + Text("\(isCredit ? "+" : "")\(entry.delta.formatted()) \(entry.unit)") + .font(.subheadline.weight(.bold).monospacedDigit()) + .foregroundStyle(isCredit ? kindColor : .primary) + Text("Bal: \(entry.balanceAfter.formatted())") + .font(.caption2.monospacedDigit()) + .foregroundStyle(.tertiary) + } + } + .padding(.vertical, 12) + } +} + +// MARK: - State Views + +struct LoadingView: View { + let title: String + + var body: some View { + VStack(spacing: 14) { + ProgressView() + .controlSize(.regular) + Text(title) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, minHeight: 200) + } +} + +struct EmptyStateView: View { + let icon: String + let title: String + let message: String + + var body: some View { + VStack(spacing: 14) { + Image(systemName: icon) + .font(.system(size: 48, weight: .thin)) + .foregroundStyle(.secondary) + .symbolRenderingMode(.hierarchical) + .padding(.bottom, 4) + VStack(spacing: 6) { + Text(title) + .font(.headline) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } + .frame(maxWidth: .infinity, minHeight: 220) + .padding(.horizontal, 32) + } +} + +struct InlineErrorView: View { + let message: String + let retry: () -> Void + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.circle.fill") + .font(.system(size: 44)) + .foregroundStyle(.red) + .symbolRenderingMode(.hierarchical) + VStack(spacing: 6) { + Text("Something went wrong") + .font(.headline) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + GlassButton("Try Again", action: retry) + } + .frame(maxWidth: .infinity, minHeight: 200) + .padding(.horizontal, 32) + } +} diff --git a/Sources/RxSubscriptionIOS/Views/PaywallView.swift b/Sources/RxSubscriptionIOS/Views/PaywallView.swift new file mode 100644 index 0000000..6a5b4dc --- /dev/null +++ b/Sources/RxSubscriptionIOS/Views/PaywallView.swift @@ -0,0 +1,112 @@ +import SwiftUI + +public enum PaywallSection: String, CaseIterable, Hashable, Identifiable, Sendable { + case plans + case topUps + case usage + case balances + case balanceHistory + + public var id: String { rawValue } + + public var title: String { + switch self { + case .plans: return "Plans" + case .topUps: return "Top-ups" + case .usage: return "Usage" + case .balances: return "Balance" + case .balanceHistory: return "History" + } + } +} + +#Preview("Complete Paywall") { + PaywallView( + client: PreviewFixtures.client(), + sections: [.plans, .topUps, .usage, .balances, .balanceHistory] + ) { + VStack(alignment: .leading, spacing: 6) { + Text("Unlock more with Pro") + .font(.largeTitle.bold()) + Text("Manage your plan, usage, and balance in one place.") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +/// A configurable subscription surface with a host-app supplied SwiftUI header. +/// Pass one section for a focused screen or several sections for a segmented dashboard. +public struct PaywallView: View { + private let client: Client + private let sections: [PaywallSection] + private let header: Header + @State private var selection: PaywallSection + + public init( + client: Client, + sections: [PaywallSection] = [.plans, .topUps], + initialSection: PaywallSection? = nil, + @ViewBuilder header: () -> Header + ) { + var seen = Set() + let unique = sections.filter { seen.insert($0).inserted } + let available = unique.isEmpty ? [.plans] : unique + self.client = client + self.sections = available + self.header = header() + _selection = State(initialValue: initialSection.flatMap { available.contains($0) ? $0 : nil } ?? available[0]) + } + + public var body: some View { + VStack(spacing: 0) { + // Header + if !(header is EmptyView) { + header + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, sections.count > 1 ? 12 : 16) + } + + // Section picker + if sections.count > 1 { + Picker("Section", selection: $selection) { + ForEach(sections) { section in + Text(section.title).tag(section) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 20) + .padding(.bottom, 4) + + Divider() + } + + // Section content + switch selection { + case .plans: + SubscriptionPlanView(client: client) + case .topUps: + TopUpView(client: client) + case .usage: + UsageView(client: client) + case .balances: + BalanceView(client: client) + case .balanceHistory: + BalanceHistoryView(client: client) + } + } + } +} + +public extension PaywallView where Header == EmptyView { + init( + client: Client, + sections: [PaywallSection] = [.plans, .topUps], + initialSection: PaywallSection? = nil + ) { + self.init(client: client, sections: sections, initialSection: initialSection) { + EmptyView() + } + } +} diff --git a/Sources/RxSubscriptionIOS/Views/PreviewSupport.swift b/Sources/RxSubscriptionIOS/Views/PreviewSupport.swift new file mode 100644 index 0000000..ed4af49 --- /dev/null +++ b/Sources/RxSubscriptionIOS/Views/PreviewSupport.swift @@ -0,0 +1,140 @@ +import Foundation + +final class RxSubscriptionPreviewURLProtocol: URLProtocol { + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let json = PreviewFixtures.response(for: request.url?.path ?? "") + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(json.utf8)) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +@MainActor +enum PreviewFixtures { + static func client() -> Client { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [RxSubscriptionPreviewURLProtocol.self] + return Client( + serverURL: URL(string: "https://preview.rxsubscription.local")!, + apiKey: "rxs_sandbox_preview", + rxlabUserID: "preview-user", + email: "preview@example.com", + displayName: "Preview User", + session: URLSession(configuration: configuration) + ) + } + + nonisolated static func response(for path: String) -> String { + switch path { + case "/api/v1/catalog": catalog + case "/api/v1/usage": usage + case "/api/v1/balances": balances + case "/api/v1/balances/ledger": ledger + case "/api/v1/checkout": checkout + default: "{}" + } + } + + nonisolated static let catalog = """ + { + "plans": [ + { + "id": "starter", "key": "starter", "name": "Starter", + "description": "Essential features for personal projects.", + "planGroup": "default", "billingInterval": "month", "intervalCount": 1, + "priceAmountCents": 499, "currency": "usd", "trialDays": 7, + "purchaseOptions": [{"provider": "stripe", "flow": "checkout"}] + }, + { + "id": "pro", "key": "pro", "name": "Pro", + "description": "Higher limits, premium tools, and priority processing.", + "planGroup": "default", "billingInterval": "month", "intervalCount": 1, + "priceAmountCents": 1499, "currency": "usd", "trialDays": 14, + "purchaseOptions": [{"provider": "stripe", "flow": "checkout"}] + } + ], + "topups": [ + { + "id": "points-100", "key": "points-100", "name": "Quick refill", + "description": "A small boost for occasional extra usage.", + "unit": "points", "amount": 100, "priceAmountCents": 199, + "currency": "usd", "eligible": true, "blockedBy": [], + "purchaseOptions": [{"provider": "stripe", "flow": "checkout"}] + }, + { + "id": "points-1000", "key": "points-1000", "name": "Power pack", + "description": "Best for a busy month or a large project.", + "unit": "points", "amount": 1000, "priceAmountCents": 1299, + "currency": "usd", "eligible": true, "blockedBy": [], + "purchaseOptions": [{"provider": "stripe", "flow": "checkout"}] + } + ] + } + """ + + nonisolated static let usage = """ + { + "usage": [ + { + "itemId": "generation", "key": "generation", "name": "Generations", + "used": 68, "limit": 100, "remaining": 32, + "periodStart": "2026-08-01T00:00:00.000Z", + "periodEnd": "2026-09-01T00:00:00.000Z", + "resetsAt": "2026-09-01T00:00:00.000Z", + "resetPolicy": "billing_period", "overagePolicy": "block" + }, + { + "itemId": "exports", "key": "exports", "name": "Report exports", + "used": 4, "limit": 20, "remaining": 16, + "periodStart": "2026-08-01T00:00:00.000Z", + "periodEnd": "2026-09-01T00:00:00.000Z", + "resetsAt": "2026-09-01T00:00:00.000Z", + "resetPolicy": "billing_period", "overagePolicy": "block" + }, + { + "itemId": "projects", "key": "projects", "name": "Projects", + "used": 12, "limit": null, "remaining": null, + "periodStart": "2026-08-01T00:00:00.000Z", "periodEnd": null, + "resetsAt": null, "resetPolicy": "never", "overagePolicy": "allow" + } + ] + } + """ + + nonisolated static let balances = """ + { + "balances": [ + {"unit": "points", "name": "Points", "amount": 2450, "available": 2325, "precision": 0}, + {"unit": "credits", "name": "AI credits", "amount": 18750, "available": 18750, "precision": 2}, + {"unit": "exports", "name": "Export tokens", "amount": 8, "available": 8, "precision": 0} + ] + } + """ + + nonisolated static let ledger = """ + { + "entries": [ + {"id": "entry-1", "kind": "topup", "unit": "points", "delta": 1000, "balanceAfter": 2450, "description": "Power pack", "referenceType": "purchase", "referenceId": "purchase-1", "createdAt": "2026-08-30T10:15:00.000Z", "metadata": null}, + {"id": "entry-2", "kind": "usage", "unit": "points", "delta": -75, "balanceAfter": 1450, "description": "Report generation", "referenceType": "usage_item", "referenceId": "generation", "createdAt": "2026-08-29T16:45:00.000Z", "metadata": null}, + {"id": "entry-3", "kind": "plan_grant", "unit": "points", "delta": 500, "balanceAfter": 1525, "description": "Pro monthly allowance", "referenceType": "subscription", "referenceId": "subscription-1", "createdAt": "2026-08-28T08:00:00.000Z", "metadata": null}, + {"id": "entry-4", "kind": "usage", "unit": "points", "delta": -25, "balanceAfter": 1025, "description": "Document export", "referenceType": "usage_item", "referenceId": "exports", "createdAt": "2026-08-27T13:20:00.000Z", "metadata": null} + ], + "total": 4, "page": 1, "pageSize": 20, "pageCount": 1 + } + """ + + nonisolated static let checkout = """ + {"checkoutUrl":"https://checkout.example.com/preview","sessionId":"preview-session","purchaseId":null,"discount":null,"promotionCodesEnabled":false} + """ +} diff --git a/Sources/RxSubscriptionIOS/Views/SubscriptionPlanView.swift b/Sources/RxSubscriptionIOS/Views/SubscriptionPlanView.swift new file mode 100644 index 0000000..17001c9 --- /dev/null +++ b/Sources/RxSubscriptionIOS/Views/SubscriptionPlanView.swift @@ -0,0 +1,389 @@ +import SwiftUI + +public struct SubscriptionPlanView: View { + @StateObject private var model: SubscriptionPlanViewModel + @Environment(\.openURL) private var openURL + private let header: Header + @State private var selectedPlanID: String? + + public init(client: Client, @ViewBuilder header: () -> Header) { + _model = StateObject(wrappedValue: SubscriptionPlanViewModel(client: client)) + self.header = header() + } + + public var body: some View { + ZStack { + ScrollView { + VStack(spacing: 24) { + header + planListContent + } + .frame(maxWidth: 620) + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 32) + .frame(maxWidth: .infinity) + } + .refreshable { await model.load(force: true) } + .task { await model.load() } + .onChange(of: model.plans) { plans in + if selectedPlanID == nil { + let recommended = plans.first(where: { isRecommended($0) }) + selectedPlanID = (recommended ?? plans.first)?.id + } + } + .alert("Purchase", isPresented: $model.showingMessage) { + Button("OK", role: .cancel) {} + } message: { + Text(model.message ?? "") + } + + if model.processingID != nil || model.isRestoring { + busyOverlay + } + } + } + + // MARK: - Content + + @ViewBuilder + private var planListContent: some View { + if model.isLoading && model.plans.isEmpty { + LoadingView(title: "Loading plans…") + } else if let error = model.error, model.plans.isEmpty { + InlineErrorView(message: error) { Task { await model.load(force: true) } } + } else if model.plans.isEmpty { + EmptyStateView( + icon: "rectangle.stack", + title: "No plans available", + message: "Active subscription plans will appear here." + ) + } else { + VStack(spacing: 12) { + Text("Choose a plan") + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + + ForEach(model.plans) { plan in + planCard(plan) + } + + purchaseButton + + if model.hasAppleProducts { + Button("Restore Purchases") { + Task { await model.restore() } + } + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + .disabled(model.isRestoring || model.processingID != nil) + } + + renewalDisclosure + } + } + } + + private func planCard(_ plan: SubscriptionPlan) -> some View { + let isSelected = selectedPlanID == plan.id + let showBadge = isRecommended(plan) + + return Button { + withAnimation(.spring(response: 0.25, dampingFraction: 0.8)) { + selectedPlanID = plan.id + } + } label: { + VStack(spacing: 0) { + if showBadge { + HStack(spacing: 6) { + Image(systemName: "sparkles") + Text("BEST VALUE") + } + .font(.caption.weight(.bold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 7) + .background(Color.accentColor) + .clipShape( + UnevenRoundedRectangle( + topLeadingRadius: 17, bottomLeadingRadius: 0, + bottomTrailingRadius: 0, topTrailingRadius: 17 + ) + ) + } + + HStack(alignment: .top, spacing: 14) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.title3) + .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 8) { + Text(plan.name) + .font(.headline) + .foregroundStyle(.primary) + if plan.trialDays > 0 { + Text("\(plan.trialDays)-day trial") + .font(.caption2.weight(.bold)) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.accentColor, in: Capsule()) + } + } + Text(intervalText(for: plan)) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + if let desc = plan.description, !desc.isEmpty { + Text(desc) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + } + + Spacer(minLength: 8) + + VStack(alignment: .trailing, spacing: 3) { + Text(model.price(for: plan)) + .font(.headline) + .foregroundStyle(.primary) + if plan.billingInterval != "one_time" { + Text("/ \(plan.billingInterval)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + isSelected ? Color.accentColor.opacity(0.10) : Color.clear, + in: showBadge + ? AnyShape(UnevenRoundedRectangle( + topLeadingRadius: 0, bottomLeadingRadius: 17, + bottomTrailingRadius: 17, topTrailingRadius: 0 + )) + : AnyShape(RoundedRectangle(cornerRadius: 17, style: .continuous)) + ) + } + .overlay { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke( + isSelected ? Color.accentColor : Color.secondary.opacity(0.3), + lineWidth: isSelected ? 2 : 1 + ) + } + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(model.processingID != nil || model.isRestoring) + .animation(.spring(response: 0.25, dampingFraction: 0.8), value: isSelected) + } + + private var purchaseButton: some View { + Button(action: purchaseSelected) { + Group { + if model.processingID != nil { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Processing…") + } + } else { + Text(actionButtonTitle) + } + } + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 15) + } + .buttonStyle(.borderedProminent) + .cornerRadius(16) + .disabled(selectedPlanID == nil || model.processingID != nil || model.isRestoring) + } + + private var renewalDisclosure: some View { + Text("Payment will be charged to your Apple Account. Subscriptions renew automatically unless canceled at least 24 hours before the end of the current period. You can manage or cancel anytime in App Store account settings.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + + private var busyOverlay: some View { + ZStack { + Color.black.opacity(0.18).ignoresSafeArea() + VStack(spacing: 12) { + ProgressView() + .controlSize(.large) + Text(model.isRestoring ? "Restoring purchases…" : "Completing purchase…") + .font(.subheadline.weight(.semibold)) + } + .padding(24) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + } + + // MARK: - Helpers + + private var actionButtonTitle: String { + guard let id = selectedPlanID, + let plan = model.plans.first(where: { $0.id == id }) else { + return "Continue" + } + if plan.trialDays > 0 { return "Start \(plan.trialDays)-Day Free Trial" } + if plan.billingInterval == "one_time" { return "Buy \(plan.name)" } + return "Continue with \(plan.name)" + } + + private func purchaseSelected() { + guard let id = selectedPlanID, + let plan = model.plans.first(where: { $0.id == id }) else { return } + Task { + if let url = await model.purchase(plan) { openURL(url) } + } + } + + private func intervalText(for plan: SubscriptionPlan) -> String { + if plan.billingInterval == "one_time" { return "One-time purchase" } + let count = plan.intervalCount + let interval = count == 1 ? plan.billingInterval : "\(plan.billingInterval)s" + return count == 1 ? "Billed every \(interval)" : "Billed every \(count) \(interval)" + } + + private func isRecommended(_ plan: SubscriptionPlan) -> Bool { + let name = plan.name.lowercased() + if name.contains("annual") || name.contains("yearly") || name.contains("year") { return true } + return plan.billingInterval == "year" + } +} + +// MARK: - Empty header convenience + +public extension SubscriptionPlanView where Header == EmptyView { + init(client: Client) { + self.init(client: client) { EmptyView() } + } +} + +// MARK: - View Model + +@MainActor +private final class SubscriptionPlanViewModel: ObservableObject { + @Published var plans: [SubscriptionPlan] = [] + @Published var products: [String: StoreProductInfo] = [:] + @Published var isLoading = false + @Published var isRestoring = false + @Published var processingID: String? + @Published var error: String? + @Published var message: String? + @Published var showingMessage = false + + let client: Client + var hasAppleProducts: Bool { + plans.contains { plan in plan.purchaseOptions.contains { $0.provider == .appleAppStore } } + } + + init(client: Client) { self.client = client } + + func load(force: Bool = false) async { + guard force || plans.isEmpty else { return } + isLoading = true + error = nil + defer { isLoading = false } + do { + let catalog = try await client.catalog() + plans = catalog.plans + let ids = catalog.plans.flatMap(\.purchaseOptions).compactMap { option in + option.provider == .appleAppStore ? option.productID : nil + } + let loaded = ids.isEmpty ? [] : try await client.storeProducts(productIDs: ids) + products = Dictionary(uniqueKeysWithValues: loaded.map { ($0.id, $0) }) + } catch { + self.error = error.localizedDescription + } + } + + func price(for plan: SubscriptionPlan) -> String { + if let id = appleOption(for: plan)?.productID, let product = products[id] { + return product.displayPrice + } + return SubscriptionFormatting.price(cents: plan.priceAmountCents, currency: plan.currency) + } + + func purchase(_ plan: SubscriptionPlan) async -> URL? { + processingID = plan.id + defer { processingID = nil } + do { + if let productID = appleOption(for: plan)?.productID { + let outcome = try await client.purchaseApple(productID: productID) + switch outcome { + case .completed: show("Purchase fulfilled successfully.") + case .pending: show("The purchase is pending approval.") + case .cancelled: break + } + return nil + } + return try await client.checkoutPlan(id: plan.id).checkoutURL + } catch { + show(error.localizedDescription) + return nil + } + } + + func restore() async { + isRestoring = true + defer { isRestoring = false } + do { + let restored = try await client.restoreApplePurchases() + show(restored.isEmpty ? "No restorable purchases found." : "Restored \(restored.count) purchase(s).") + } catch { + show(error.localizedDescription) + } + } + + private func appleOption(for plan: SubscriptionPlan) -> PurchaseOption? { + plan.purchaseOptions.first { $0.provider == .appleAppStore && $0.flow == .storeKit } + } + + private func show(_ message: String) { + self.message = message + showingMessage = true + } +} + +// MARK: - Preview + +#Preview("Subscription Plans") { + NavigationStack { + SubscriptionPlanView(client: PreviewFixtures.client()) { + VStack(spacing: 14) { + ZStack { + Circle() + .fill(Color.accentColor.opacity(0.14)) + Image(systemName: "sparkles") + .font(.system(size: 38, weight: .semibold)) + .foregroundStyle(Color.accentColor) + } + .frame(width: 82, height: 82) + + VStack(spacing: 8) { + Text("Plans that fit your work") + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + Text("Start free and upgrade whenever you need more.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity) + .padding(.top, 8) + } + .navigationTitle("Subscriptions") + } +} diff --git a/Sources/RxSubscriptionIOS/Views/TopUpView.swift b/Sources/RxSubscriptionIOS/Views/TopUpView.swift new file mode 100644 index 0000000..6c5411e --- /dev/null +++ b/Sources/RxSubscriptionIOS/Views/TopUpView.swift @@ -0,0 +1,292 @@ +import SwiftUI + +public struct TopUpView: View { + @StateObject private var model: TopUpViewModel + @Environment(\.openURL) private var openURL + private let header: Header + + public init(client: Client, @ViewBuilder header: () -> Header) { + _model = StateObject(wrappedValue: TopUpViewModel(client: client)) + self.header = header() + } + + public var body: some View { + ZStack { + ScrollView { + VStack(spacing: 24) { + header + content + } + .frame(maxWidth: 620) + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 32) + .frame(maxWidth: .infinity) + } + .refreshable { await model.load(force: true) } + .task { await model.load() } + .alert("Top Up", isPresented: $model.showingMessage) { + Button("OK", role: .cancel) {} + } message: { + Text(model.message ?? "") + } + + if model.processingID != nil { + busyOverlay + } + } + } + + @ViewBuilder + private var content: some View { + if model.isLoading && model.topUps.isEmpty { + LoadingView(title: "Loading top-ups…") + } else if let error = model.error, model.topUps.isEmpty { + InlineErrorView(message: error) { Task { await model.load(force: true) } } + } else if model.topUps.isEmpty { + EmptyStateView( + icon: "plus.circle", + title: "No top-ups available", + message: "Eligible balance packs will appear here." + ) + } else { + topUpList + } + } + + private var topUpList: some View { + let eligible = model.topUps.filter { $0.eligible != false } + let ineligible = model.topUps.filter { $0.eligible == false } + + return VStack(spacing: 24) { + if !eligible.isEmpty { + topUpSection(title: "Available", items: eligible) + } + if !ineligible.isEmpty { + topUpSection(title: "Not Available", items: ineligible) + } + } + } + + private func topUpSection(title: String, items: [TopUpProduct]) -> some View { + VStack(spacing: 12) { + Text(title) + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + + ForEach(items) { topUp in + topUpCard(topUp) + } + } + } + + private func topUpCard(_ topUp: TopUpProduct) -> some View { + let isEligible = topUp.eligible != false + let isProcessing = model.processingID == topUp.id + + return HStack(alignment: .center, spacing: 14) { + // Amount badge + VStack(spacing: 3) { + Text(topUp.amount.formatted()) + .font(.title3.weight(.bold).monospacedDigit()) + .foregroundStyle(isEligible ? Color.accentColor : Color.secondary) + Text(topUp.unit ?? "units") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .tracking(0.3) + } + .frame(width: 68) + .padding(.vertical, 14) + .tintedRect(isEligible ? Color.accentColor : Color.gray, cornerRadius: 12) + + // Info + VStack(alignment: .leading, spacing: 4) { + Text(topUp.name) + .font(.headline) + .foregroundStyle(.primary) + if let description = topUp.description, !description.isEmpty { + Text(description) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + if !isEligible { + Label(eligibilityText(for: topUp), systemImage: "lock.fill") + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.top, 2) + } + } + + Spacer(minLength: 0) + + // Price + buy + VStack(alignment: .trailing, spacing: 8) { + Text(model.price(for: topUp)) + .font(.subheadline.weight(.bold).monospacedDigit()) + .foregroundStyle(.primary) + + Button { + Task { + if let url = await model.purchase(topUp) { openURL(url) } + } + } label: { + Group { + if isProcessing { + ProgressView().controlSize(.small) + } else { + Text("Buy").font(.footnote.weight(.semibold)) + } + } + .frame(width: 52) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(isProcessing || !isEligible || model.processingID != nil) + } + } + .padding(16) + .cardGlass(cornerRadius: 16) + .opacity(isEligible ? 1 : 0.55) + } + + private var busyOverlay: some View { + ZStack { + Color.black.opacity(0.18).ignoresSafeArea() + VStack(spacing: 12) { + ProgressView() + .controlSize(.large) + Text("Completing purchase…") + .font(.subheadline.weight(.semibold)) + } + .padding(24) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + } + + private func eligibilityText(for topUp: TopUpProduct) -> String { + let rules = topUp.blockedBy?.map(\.ruleType) ?? [] + if rules.contains("purchase_limit") { return "Purchase limit reached" } + if rules.contains("requires_role") { return "Membership required" } + if rules.contains("requires_active_plan") { return "Specific plan required" } + if rules.contains("requires_any_plan") { return "Active plan required" } + return "Not currently eligible" + } +} + +public extension TopUpView where Header == EmptyView { + init(client: Client) { + self.init(client: client) { EmptyView() } + } +} + +// MARK: - View Model + +@MainActor +private final class TopUpViewModel: ObservableObject { + @Published var topUps: [TopUpProduct] = [] + @Published var products: [String: StoreProductInfo] = [:] + @Published var isLoading = false + @Published var processingID: String? + @Published var error: String? + @Published var message: String? + @Published var showingMessage = false + + let client: Client + + init(client: Client) { self.client = client } + + func load(force: Bool = false) async { + guard force || topUps.isEmpty else { return } + isLoading = true + error = nil + defer { isLoading = false } + do { + let catalog = try await client.catalog() + topUps = catalog.topups + let ids = catalog.topups.flatMap(\.purchaseOptions).compactMap { option in + option.provider == .appleAppStore ? option.productID : nil + } + let loaded = ids.isEmpty ? [] : try await client.storeProducts(productIDs: ids) + products = Dictionary(uniqueKeysWithValues: loaded.map { ($0.id, $0) }) + } catch { + self.error = error.localizedDescription + } + } + + func price(for topUp: TopUpProduct) -> String { + if let id = appleOption(for: topUp)?.productID, let product = products[id] { + return product.displayPrice + } + return SubscriptionFormatting.price(cents: topUp.priceAmountCents, currency: topUp.currency) + } + + func purchase(_ topUp: TopUpProduct) async -> URL? { + guard topUp.eligible != false else { return nil } + processingID = topUp.id + defer { processingID = nil } + do { + if let productID = appleOption(for: topUp)?.productID { + let outcome = try await client.purchaseApple(productID: productID) + switch outcome { + case .completed: + show("Balance added successfully.") + await load(force: true) + case .pending: + show("The purchase is pending approval.") + case .cancelled: + break + } + return nil + } + return try await client.checkoutTopUp(id: topUp.id).checkoutURL + } catch { + show(error.localizedDescription) + return nil + } + } + + private func appleOption(for topUp: TopUpProduct) -> PurchaseOption? { + topUp.purchaseOptions.first { $0.provider == .appleAppStore && $0.flow == .storeKit } + } + + private func show(_ message: String) { + self.message = message + showingMessage = true + } +} + +// MARK: - Preview + +#Preview("Top-ups") { + NavigationStack { + TopUpView(client: PreviewFixtures.client()) { + VStack(spacing: 14) { + ZStack { + Circle() + .fill(Color.accentColor.opacity(0.14)) + Image(systemName: "plus.circle.fill") + .font(.system(size: 38, weight: .semibold)) + .foregroundStyle(Color.accentColor) + } + .frame(width: 82, height: 82) + + VStack(spacing: 8) { + Text("Need a little more?") + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + Text("Top up your balance without changing your plan.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity) + .padding(.top, 8) + } + .navigationTitle("Top-ups") + } +} diff --git a/Sources/RxSubscriptionIOS/Views/UsageView.swift b/Sources/RxSubscriptionIOS/Views/UsageView.swift new file mode 100644 index 0000000..265645e --- /dev/null +++ b/Sources/RxSubscriptionIOS/Views/UsageView.swift @@ -0,0 +1,68 @@ +import SwiftUI + +public struct UsageView: View { + @StateObject private var model: UsageViewModel + + public init(client: Client) { + _model = StateObject(wrappedValue: UsageViewModel(client: client)) + } + + public var body: some View { + ScrollView { + LazyVStack(spacing: 0) { + if model.isLoading && model.items.isEmpty { + LoadingView(title: "Loading usage…") + } else if let error = model.error, model.items.isEmpty { + InlineErrorView(message: error) { Task { await model.load() } } + } else if model.items.isEmpty { + EmptyStateView( + icon: "gauge.with.dots.needle.33percent", + title: "No usage meters", + message: "Usage allowances will appear here when configured." + ) + } else { + // Group all rows in a single card with internal dividers + VStack(spacing: 0) { + ForEach(Array(model.items.enumerated()), id: \.element.id) { index, item in + UsageItemRow(item: item) + .padding(.horizontal, 20) + if index < model.items.count - 1 { + Divider().padding(.horizontal, 20) + } + } + } + .cardGlass(cornerRadius: 20) + } + } + .padding(20) + } + .refreshable { await model.load() } + .task { if model.items.isEmpty { await model.load() } } + } +} + +#Preview("Usage") { + NavigationStack { + UsageView(client: PreviewFixtures.client()) + .navigationTitle("Usage") + } +} + +@MainActor +private final class UsageViewModel: ObservableObject { + @Published var items: [UsageStatus] = [] + @Published var isLoading = false + @Published var error: String? + + let client: Client + + init(client: Client) { self.client = client } + + func load() async { + isLoading = true + error = nil + defer { isLoading = false } + do { items = try await client.usage() } + catch { self.error = error.localizedDescription } + } +} diff --git a/Tests/RxSubscriptionIOSTests/ClientTests.swift b/Tests/RxSubscriptionIOSTests/ClientTests.swift new file mode 100644 index 0000000..46f998b --- /dev/null +++ b/Tests/RxSubscriptionIOSTests/ClientTests.swift @@ -0,0 +1,314 @@ +import Foundation +import XCTest +@testable import RxSubscriptionIOS + +final class URLProtocolStub: URLProtocol { + static var handler: ((URLRequest) throws -> (Int, String))? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + do { + guard let handler = Self.handler else { throw URLError(.badServerResponse) } + let (status, json) = try handler(request) + let response = HTTPURLResponse( + url: request.url!, + statusCode: status, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(json.utf8)) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +@MainActor +final class ClientTests: XCTestCase { + private var client: Client! + + override func setUp() { + super.setUp() + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [URLProtocolStub.self] + client = Client( + serverURL: URL(string: "https://subscriptions.example.test")!, + apiKey: "rxs_sandbox_test", + rxlabUserID: "user-42", + email: "reader@example.test", + displayName: "Reader", + session: URLSession(configuration: configuration) + ) + } + + override func tearDown() { + URLProtocolStub.handler = nil + client = nil + super.tearDown() + } + + func testCatalogUsesAPIKeyUserAndDecodesStoreKitMapping() async throws { + URLProtocolStub.handler = { request in + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Api-Key"), "rxs_sandbox_test") + XCTAssertEqual(request.url?.path, "/api/v1/catalog") + XCTAssertEqual( + URLComponents(url: request.url!, resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "rxlabUserId" })?.value, + "user-42" + ) + return (200, Self.catalogJSON) + } + + let catalog = try await client.catalog() + XCTAssertEqual(catalog.plans.first?.name, "Pro") + XCTAssertEqual(catalog.plans.first?.purchaseOptions.last?.provider, .appleAppStore) + XCTAssertEqual(catalog.plans.first?.purchaseOptions.last?.productID, "app.pro.monthly") + XCTAssertEqual(catalog.topups.first?.eligible, true) + } + + func testUsageDenialAtHTTP402DecodesAsResult() async throws { + URLProtocolStub.handler = { request in + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.path, "/api/v1/usage") + let body = try XCTUnwrap(Self.bodyData(from: request)) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertEqual(object["rxlabUserId"] as? String, "user-42") + XCTAssertEqual(object["item"] as? String, "generation") + return (402, """ + {"allowed":false,"reason":"limit_exceeded","used":10,"limit":10,"remaining":0,"chargedUnits":0,"periodEnd":"2026-09-01T00:00:00.000Z","duplicate":false} + """) + } + + let result = try await client.recordUsage(item: "generation", idempotencyKey: "turn-1") + XCTAssertFalse(result.allowed) + XCTAssertEqual(result.reason, "limit_exceeded") + XCTAssertEqual(result.remaining, 0) + } + + func testServerErrorRetainsMachineReadablePayload() async throws { + URLProtocolStub.handler = { _ in + (401, """ + {"error":"invalid_api_key","error_description":"API key is invalid or revoked"} + """) + } + + do { + _ = try await client.balances() + XCTFail("Expected an API error") + } catch ClientError.server(let status, let payload, _) { + XCTAssertEqual(status, 401) + XCTAssertEqual(payload?.error, "invalid_api_key") + XCTAssertEqual(payload?.errorDescription, "API key is invalid or revoked") + } + } + + func testCompletePublicAPISurfaceBuildsAndDecodesRequests() async throws { + URLProtocolStub.handler = { request in + let path = request.url!.path + let method = request.httpMethod ?? "GET" + switch (method, path) { + case ("GET", "/api/v1/entitlements"): + return (200, Self.entitlementsJSON) + case ("GET", "/api/v1/balances"): + return (200, Self.balancesJSON) + case ("POST", "/api/v1/balances"): + return (200, "{\"entryId\":\"entry-1\",\"duplicate\":false,\"balanceAfter\":125}") + case ("GET", "/api/v1/balances/ledger"): + return (200, Self.ledgerJSON) + case ("POST", "/api/v1/balances/reserve"): + return (200, Self.reservationResultJSON) + case ("GET", "/api/v1/balances/reservations"): + return (200, Self.reservationJSON) + case ("GET", let route) where route.hasPrefix("/api/v1/balances/reservations/"): + return (200, Self.reservationJSON) + case ("POST", let route) where route.hasSuffix("/increase"): + return (200, Self.reservationResultJSON) + case ("POST", let route) where route.hasSuffix("/settle"): + return (200, Self.settlementJSON) + case ("POST", let route) where route.hasSuffix("/release"): + return (200, Self.releaseJSON) + case ("GET", "/api/v1/usage"): + return (200, Self.usageJSON) + case ("GET", "/api/v1/usage/statistics"): + return (200, Self.usageSeriesJSON) + case ("GET", "/api/v1/balances/consumption"): + return (200, Self.consumptionSeriesJSON) + case ("POST", "/api/v1/checkout"): + let body = try XCTUnwrap(Self.bodyData(from: request)) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + if object["kind"] as? String == "portal" { + return (200, "{\"url\":\"https://billing.example.test/portal\"}") + } + return (200, Self.checkoutJSON) + case ("POST", "/api/v1/coupons/validate"): + return (200, Self.couponJSON) + case ("GET", "/api/v1/purchases"): + return (200, Self.purchasesJSON) + case ("GET", "/api/v1/invoices"): + return (200, Self.invoicesJSON) + case ("POST", "/api/v1/iap/apple/account-token"): + return (200, "{\"appAccountToken\":\"492f2a34-88cf-4faa-a6db-bf1b145f899c\",\"environment\":\"sandbox\"}") + case ("PUT", "/api/v1/iap/apple/consumption-consent"): + return (200, "{\"consented\":true,\"updatedAt\":\"2026-08-30T12:00:00.000Z\"}") + case ("POST", "/api/v1/iap/apple/transactions"): + return (200, Self.fulfillmentJSON) + default: + XCTFail("Unhandled request: \(method) \(path)") + return (500, "{\"error\":\"unhandled\"}") + } + } + + _ = try await client.entitlements() + _ = try await client.balances() + _ = try await client.adjustBalance( + unit: "points", + amount: 25, + operation: .credit, + idempotencyKey: "credit-1" + ) + _ = try await client.ledger() + let held = try await client.reserveBalance( + unit: "points", + amount: 10, + idempotencyKey: "hold-1" + ) + _ = try await client.reservation(id: held.reservationID) + _ = try await client.reservation(idempotencyKey: "hold-1") + _ = try await client.increaseReservation(id: held.reservationID, amount: 5, idempotencyKey: "grow-1") + _ = try await client.settleReservation(id: held.reservationID, amount: 5, idempotencyKey: "settle-1") + _ = try await client.releaseReservation(id: held.reservationID, idempotencyKey: "release-1") + _ = try await client.usage() + + let from = Date(timeIntervalSince1970: 1_787_952_000) + let to = Date(timeIntervalSince1970: 1_788_038_400) + _ = try await client.usageStatistics(from: from, to: to, groupByItem: true) + _ = try await client.consumptionStatistics(from: from, to: to, groupBy: .kind) + _ = try await client.checkoutPlan(id: "plan-pro") + _ = try await client.checkoutTopUp(id: "topup-100") + _ = try await client.billingPortal() + _ = try await client.validateCoupon(code: "SAVE10", planID: "plan-pro") + _ = try await client.purchases() + _ = try await client.invoices() + _ = try await client.appleAccountToken() + _ = try await client.setAppleConsumptionConsent(true) + let fulfillment = try await client.submitAppleTransaction("header.payload.signature") + XCTAssertEqual(fulfillment.transaction.productID, "app.pro.monthly") + } + + func testFormattingUsesMinorUnitsAndPrecision() { + XCTAssertFalse(SubscriptionFormatting.price(cents: 999, currency: "usd").isEmpty) + XCTAssertEqual(SubscriptionFormatting.balance(12_345, precision: 2), "123.45") + } + + private static func bodyData(from request: URLRequest) -> Data? { + if let body = request.httpBody { return body } + guard let stream = request.httpBodyStream else { return nil } + stream.open() + defer { stream.close() } + var data = Data() + let buffer = UnsafeMutablePointer.allocate(capacity: 1_024) + defer { buffer.deallocate() } + while stream.hasBytesAvailable { + let count = stream.read(buffer, maxLength: 1_024) + if count <= 0 { break } + data.append(buffer, count: count) + } + return data + } + + private static let catalogJSON = """ + { + "plans":[{ + "id":"plan-pro","key":"pro","name":"Pro","description":"Full access", + "planGroup":"default","billingInterval":"month","intervalCount":1, + "priceAmountCents":999,"currency":"usd","trialDays":7, + "purchaseOptions":[ + {"provider":"stripe","flow":"checkout"}, + {"provider":"apple_app_store","flow":"storekit","productId":"app.pro.monthly","productType":"auto_renewable_subscription"} + ] + }], + "topups":[{ + "id":"topup-100","key":"points-100","name":"100 points","description":null, + "unit":"points","amount":100,"priceAmountCents":199,"currency":"usd", + "eligible":true,"blockedBy":[], + "purchaseOptions":[{"provider":"apple_app_store","flow":"storekit","productId":"app.points.100","productType":"consumable"}] + }] + } + """ + + private static let entitlementsJSON = """ + { + "user":{"id":"local-user","rxlabUserId":"user-42","level":2,"levelKey":"pro"}, + "plans":[{ + "subscriptionId":"sub-1","purchaseId":null,"planId":"plan-pro","planKey":"pro","planName":"Pro","planGroup":"default", + "status":"active","currentPeriodStart":"2026-08-01T00:00:00.000Z","currentPeriodEnd":"2026-09-01T00:00:00.000Z", + "cancelAtPeriodEnd":false,"billingProvider":"apple_app_store","providerProductId":"app.pro.monthly" + }], + "roles":["subscriber"],"permissions":["read:reports:all"],"features":{"exports":"enabled"}, + "balances":[{"unit":"points","name":"Points","symbol":"pt","precision":0,"amount":100,"available":95}], + "usage":[{"key":"generation","name":"Generations","used":2,"limit":10,"remaining":8,"resetsAt":"2026-09-01T00:00:00.000Z","resetPolicy":"billing_period"}] + } + """ + + private static let balancesJSON = """ + {"balances":[{"unit":"points","name":"Points","amount":100,"available":95,"precision":0}]} + """ + + private static let ledgerJSON = """ + {"entries":[{"id":"entry-1","kind":"topup","unit":"points","delta":100,"balanceAfter":100,"description":"App Store top-up","referenceType":"store_transaction","referenceId":"store-1","createdAt":"2026-08-30T12:00:00.000Z","metadata":null}],"total":1,"page":1,"pageSize":20,"pageCount":1} + """ + + private static let reservationResultJSON = """ + {"reservationId":"reservation-1","amount":10,"available":85,"expiresAt":"2026-08-30T12:30:00.000Z","status":"open","duplicate":false} + """ + + private static let reservationJSON = """ + {"reservation":{"reservationId":"reservation-1","rxlabUserId":"user-42","unit":"points","initialAmount":10,"remainingReserved":10,"status":"open","description":"Balance reservation","metadata":null,"requestedAmount":0,"settledAmount":0,"shortfallAmount":0,"releasedAmount":0,"available":85,"balanceAfter":null,"expiresAt":"2026-08-30T12:30:00.000Z","releaseReason":null,"entryId":null,"createdAt":"2026-08-30T12:00:00.000Z","updatedAt":"2026-08-30T12:00:00.000Z","closedAt":null}} + """ + + private static let settlementJSON = """ + {"reservationId":"reservation-1","entryId":"entry-2","operationRequestedAmount":5,"operationSettledAmount":5,"operationShortfallAmount":0,"requestedAmount":5,"settledAmount":5,"shortfallAmount":0,"remainingReserved":5,"balanceAfter":95,"status":"open","expiresAt":"2026-08-30T12:30:00.000Z","duplicate":false} + """ + + private static let releaseJSON = """ + {"reservationId":"reservation-1","released":true,"releasedAmount":5,"remainingReserved":0,"balanceAfter":95,"status":"closed","duplicate":false} + """ + + private static let usageJSON = """ + {"usage":[{"itemId":"usage-1","key":"generation","name":"Generations","used":2,"limit":10,"remaining":8,"periodStart":"2026-08-01T00:00:00.000Z","periodEnd":"2026-09-01T00:00:00.000Z","resetsAt":"2026-09-01T00:00:00.000Z","resetPolicy":"billing_period","overagePolicy":"block"}]} + """ + + private static let usageSeriesJSON = """ + {"from":"2026-08-29T00:00:00.000Z","to":"2026-08-30T00:00:00.000Z","granularity":"day","totals":[{"start":"2026-08-29T00:00:00.000Z","amount":2,"consumed":2,"eventCount":1,"chargedUnits":0}],"groups":[]} + """ + + private static let consumptionSeriesJSON = """ + {"from":"2026-08-29T00:00:00.000Z","to":"2026-08-30T00:00:00.000Z","granularity":"day","totals":[{"start":"2026-08-29T00:00:00.000Z","spent":2,"granted":0,"net":-2,"entryCount":1}],"groups":[],"unit":null} + """ + + private static let checkoutJSON = """ + {"checkoutUrl":"https://checkout.example.test/session","sessionId":"cs_test","purchaseId":null,"discount":null,"promotionCodesEnabled":true} + """ + + private static let couponJSON = """ + {"valid":true,"code":"SAVE10","name":"Save 10","description":null,"terms":"10% off","duration":"once","durationInMonths":null,"discountCents":100,"totalCents":899,"currency":"usd","capped":false,"reason":null,"blockers":[]} + """ + + private static let purchasesJSON = """ + {"purchases":[{"id":"purchase-1","kind":"topup","status":"paid","unit":"points","unitsGranted":100,"amountCents":199,"currency":"usd","billingProvider":"apple_app_store","providerTransactionId":"tx-1","providerProductId":"app.points.100","quantity":1,"priceMilliunits":1990,"fulfillmentFailureCode":null,"createdAt":"2026-08-30T12:00:00.000Z","hostedInvoiceUrl":null,"invoicePdfUrl":null}],"total":1,"page":1,"pageSize":20,"pageCount":1} + """ + + private static let invoicesJSON = """ + {"invoices":[{"id":"in-1","number":"INV-1","description":"Pro","status":"paid","amountCents":999,"currency":"usd","createdAt":"2026-08-30T12:00:00.000Z","hostedInvoiceUrl":"https://invoice.example.test/in-1","invoicePdfUrl":null}],"pagination":{"hasMore":false,"firstCursor":"in-1","lastCursor":"in-1"}} + """ + + private static let fulfillmentJSON = """ + {"processed":"new","transaction":{"transactionId":"tx-1","originalTransactionId":"original-1","productId":"app.pro.monthly","productType":"auto_renewable_subscription","environment":"sandbox","quantity":1,"priceMilliunits":9990,"currency":"usd","purchaseAt":"2026-08-30T12:00:00.000Z","expiresAt":"2026-09-30T12:00:00.000Z","revokedAt":null},"purchase":null,"subscription":null} + """ +} diff --git a/Tests/RxSubscriptionIOSTests/RxSubscriptionIOSTests.swift b/Tests/RxSubscriptionIOSTests/RxSubscriptionIOSTests.swift new file mode 100644 index 0000000..efd8eb6 --- /dev/null +++ b/Tests/RxSubscriptionIOSTests/RxSubscriptionIOSTests.swift @@ -0,0 +1 @@ +// Test coverage lives in ClientTests.swift. From a6b17cd2fe33d96d85559f398bc76fdf3e99d43e Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:11:14 +0800 Subject: [PATCH 2/2] ci: add package build and test workflow --- .github/workflows/ios-ci.yml | 52 +++++++++++++++++++ Package.swift | 4 +- .../Views/SubscriptionPlanView.swift | 2 +- 3 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ios-ci.yml diff --git a/.github/workflows/ios-ci.yml b/.github/workflows/ios-ci.yml new file mode 100644 index 0000000..d5b957d --- /dev/null +++ b/.github/workflows/ios-ci.yml @@ -0,0 +1,52 @@ +name: iOS Package CI + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test Package + runs-on: macos-26 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Show Swift Version + run: swift --version + + - name: Run Tests + run: swift test --parallel + + build-macos: + name: Build macOS Package + runs-on: macos-26 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Build Release + run: swift build --configuration release + + build-ios: + name: Build iOS Package + runs-on: macos-26 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Build iOS 26 Release + run: swift build --configuration release --triple arm64-apple-ios26.0 --sdk "$(xcrun --sdk iphoneos --show-sdk-path)" diff --git a/Package.swift b/Package.swift index 7ce8523..ce094a0 100644 --- a/Package.swift +++ b/Package.swift @@ -4,8 +4,8 @@ import PackageDescription let package = Package( name: "RxSubscriptionIOS", platforms: [ - .iOS(.v26), - .macOS(.v26), + .iOS("26.0"), + .macOS("26.0"), ], products: [ .library(name: "RxSubscriptionIOS", targets: ["RxSubscriptionIOS"]), diff --git a/Sources/RxSubscriptionIOS/Views/SubscriptionPlanView.swift b/Sources/RxSubscriptionIOS/Views/SubscriptionPlanView.swift index 17001c9..8e8b1de 100644 --- a/Sources/RxSubscriptionIOS/Views/SubscriptionPlanView.swift +++ b/Sources/RxSubscriptionIOS/Views/SubscriptionPlanView.swift @@ -26,7 +26,7 @@ public struct SubscriptionPlanView: View { } .refreshable { await model.load(force: true) } .task { await model.load() } - .onChange(of: model.plans) { plans in + .onChange(of: model.plans) { _, plans in if selectedPlanID == nil { let recommended = plans.first(where: { isRecommended($0) }) selectedPlanID = (recommended ?? plans.first)?.id