diff --git a/Makefile b/Makefile index a4b64b3..6ff6133 100644 --- a/Makefile +++ b/Makefile @@ -6,8 +6,9 @@ docs: --disable-indexing \ --transform-for-static-hosting \ --target AmoreLicensing \ - --output-path ./docs - # --enable-experimental-combined-documentation \ + --target AmoreStore \ + --output-path ./docs \ + --enable-experimental-combined-documentation \ docs-preview: swift package --disable-sandbox preview-documentation --target AmoreLicensing diff --git a/Package.swift b/Package.swift index 82e3bcc..0b84769 100644 --- a/Package.swift +++ b/Package.swift @@ -12,6 +12,10 @@ let package = Package( name: "AmoreLicensing", targets: ["AmoreLicensing"] ), + .library( + name: "AmoreStore", + targets: ["AmoreStore"] + ), ], dependencies: [ .package(url: "https://github.com/vapor/jwt-kit.git", from: "5.0.0"), @@ -24,6 +28,7 @@ let package = Package( .product(name: "JWTKit", package: "jwt-kit"), ], ), + .target(name: "AmoreStore"), .testTarget( name: "AmoreLicensingTests", dependencies: [ @@ -31,6 +36,10 @@ let package = Package( .product(name: "JWTKit", package: "jwt-kit"), ] ), + .testTarget( + name: "AmoreStoreTests", + dependencies: ["AmoreStore"] + ), ], swiftLanguageModes: [.v6] ) diff --git a/Sources/AmoreStore/AmoreStore.swift b/Sources/AmoreStore/AmoreStore.swift new file mode 100644 index 0000000..2b9c1ce --- /dev/null +++ b/Sources/AmoreStore/AmoreStore.swift @@ -0,0 +1,51 @@ +import Foundation + +extension URL { + /// Default Amore licensing server. Internal to `AmoreStore` so it does not + /// collide with the public `URL.amoreServer` declared in `AmoreLicensing`. + static let amoreServer = URL(string: "https://api.amore.computer")! +} + +/// Lists the products configured for an Amore-licensed app. +/// +/// Fetch products with ``products()``, then read ``Product/checkoutURL`` on a +/// returned product to send a customer to Stripe checkout. Use this to build +/// paywalls, pickers, or any view that displays the products and prices +/// configured in the licensing dashboard. +public struct AmoreStore: Sendable { + private let bundleIdentifier: String + private let productsClient: ProductsClient + + /// Creates a store client for the given bundle identifier, targeting the Amore server. + /// - Parameter bundleIdentifier: The app's bundle identifier. Defaults to `Bundle.main.bundleIdentifier`. + public init(bundleIdentifier: String? = nil) { + self.init(bundleIdentifier: bundleIdentifier, baseURL: .amoreServer) + } + + /// Creates a store client for the given bundle identifier and server URL. + /// - Parameters: + /// - bundleIdentifier: The app's bundle identifier. Defaults to `Bundle.main.bundleIdentifier`. + /// - baseURL: The licensing server base URL. + public init(bundleIdentifier: String? = nil, baseURL: URL) { + self.bundleIdentifier = bundleIdentifier ?? Bundle.main.bundleIdentifier ?? "" + self.productsClient = HTTPProductsClient(baseURL: baseURL) + } + + init(bundleIdentifier: String, productsClient: ProductsClient) { + self.bundleIdentifier = bundleIdentifier + self.productsClient = productsClient + } + + /// Returns the products configured for this app. Purchasable products carry a + /// non-`nil` ``Product/checkoutURL``. + /// - Throws: ``StoreError`` if the request fails. + public func products() async throws(StoreError) -> [Product] { + do { + return try await productsClient.fetchProducts(bundleIdentifier: bundleIdentifier) + } catch let error as StoreError { + throw error + } catch { + throw .network(error.localizedDescription) + } + } +} diff --git a/Sources/AmoreStore/Documentation.docc/Getting Started.md b/Sources/AmoreStore/Documentation.docc/Getting Started.md new file mode 100644 index 0000000..a1355d5 --- /dev/null +++ b/Sources/AmoreStore/Documentation.docc/Getting Started.md @@ -0,0 +1,61 @@ +# Getting Started + +This article describes how to get started with AmoreStore. + +## Installation + +In Xcode, go to **File → Add Package Dependencies…** and enter: + +``` +https://github.com/AmoreComputer/AmoreKit +``` + +Or add it to your `Package.swift`: + +```swift +.package(url: "https://github.com/AmoreComputer/AmoreKit", from: "0.1") +``` + +## AmoreStore + +To get started with AmoreStore, create an instance of ``AmoreStore``. By default it uses your app's `Bundle.main.bundleIdentifier`. + +```swift +let store = AmoreStore() +``` + +## Fetching Products + +Call ``AmoreStore/products()`` to fetch the products configured for your app. + +```swift +let products = try await store.products() +``` + +> Note: ``AmoreStore/products()`` throws ``StoreError`` with detailed information about what went wrong. + +## Displaying Prices + +Each ``Product`` carries an optional ``Product/price`` with a localized, display-ready string. + +```swift +ForEach(products) { product in + HStack { + Text(product.name) + Spacer() + if let displayPrice = product.displayPrice { + Text(displayPrice) + } + } +} +``` + +Use ``Price/recurringInterval`` to tell one-time purchases from subscriptions. + +## Checkout + +Every purchasable ``Product`` carries a ``Product/checkoutURL``. Open it to send the customer to Stripe checkout. + +```swift +NSWorkspace.shared.open(product.checkoutURL) +``` diff --git a/Sources/AmoreStore/Documentation.docc/Index.md b/Sources/AmoreStore/Documentation.docc/Index.md new file mode 100644 index 0000000..7ef59c9 --- /dev/null +++ b/Sources/AmoreStore/Documentation.docc/Index.md @@ -0,0 +1,26 @@ +# ``AmoreStore`` + +A macOS SDK for listing an app's products and sending customers to checkout. + +## Overview + +AmoreStore is the store SDK for [Amore](https://amore.computer). + +AmoreStore fetches the products configured for your app in the licensing dashboard and exposes their prices and checkout URLs. Use it to build paywalls, pickers, or any view that displays what a customer can buy. + +## Topics + +### Articles + +- + +### Essentials + +- ``AmoreStore`` +- ``Product`` +- ``Price`` +- ``RecurringInterval`` + +### Errors + +- ``StoreError`` diff --git a/Sources/AmoreStore/Documentation.docc/images/heart.svg b/Sources/AmoreStore/Documentation.docc/images/heart.svg new file mode 100644 index 0000000..4a7d72e --- /dev/null +++ b/Sources/AmoreStore/Documentation.docc/images/heart.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Sources/AmoreStore/Documentation.docc/theme-settings.json b/Sources/AmoreStore/Documentation.docc/theme-settings.json new file mode 100644 index 0000000..c437a68 --- /dev/null +++ b/Sources/AmoreStore/Documentation.docc/theme-settings.json @@ -0,0 +1,35 @@ +{ + "theme": { + "aside": { + "border-radius": "6px", + "border-style": "solid", + "border-width": "2px" + }, + "border-radius": "0", + "button": { + "border-radius": "16px", + "border-width": "1px", + "border-style": "solid" + }, + "code": { + "border-radius": "16px", + "border-width": "1px", + "border-style": "solid" + }, + "color": { + "amore": "#f09000", + "documentation-intro-fill": { "dark": "#000", "light": "#fff" }, + "documentation-intro-accent": "var(--color-jwtkit)", + "logo-base": { "dark": "#fff", "light": "#000" }, + "logo-shape": { "dark": "#000", "light": "#fff" }, + "link": { "dark": "var(--color-amore)", "light": "var(--color-amore)" }, + "fill": { "dark": "#222", "light": "#eee" }, + "button-background": "var(--color-amore)", + "fill-blue": "var(--color-amore)", + "figure-blue": "var(--color-amore)" + }, + "icons": { + "technology": "/images/AmoreStore/heart.svg" + } + } +} diff --git a/Sources/AmoreStore/HTTPProductsClient.swift b/Sources/AmoreStore/HTTPProductsClient.swift new file mode 100644 index 0000000..c5dd69f --- /dev/null +++ b/Sources/AmoreStore/HTTPProductsClient.swift @@ -0,0 +1,46 @@ +import Foundation + +struct HTTPProductsClient: ProductsClient { + private let baseURL: URL + + init(baseURL: URL) { + self.baseURL = baseURL + } + + func fetchProducts(bundleIdentifier: String) async throws -> [Product] { + let url = baseURL + .appendingPathComponent("v1/public/apps") + .appendingPathComponent(bundleIdentifier) + .appendingPathComponent("products") + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + throw StoreError.network(error.localizedDescription) + } + + guard let http = response as? HTTPURLResponse else { + throw StoreError.network("Invalid response from server") + } + switch http.statusCode { + case 200...299: + do { + return try JSONDecoder().decode([Product].self, from: data) + } catch { + throw StoreError.network("Could not decode product list: \(error.localizedDescription)") + } + case 404: + throw StoreError.appNotFound + case 429: + throw StoreError.rateLimited + default: + throw StoreError.serverError(statusCode: http.statusCode) + } + } +} diff --git a/Sources/AmoreStore/Models/Price.swift b/Sources/AmoreStore/Models/Price.swift new file mode 100644 index 0000000..4edbbd7 --- /dev/null +++ b/Sources/AmoreStore/Models/Price.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Pricing information for a product as configured in Stripe. +public struct Price: Hashable, Codable, Sendable { + /// Amount in the smallest unit of `currency` (e.g. cents for USD, yen for JPY). + public var unitAmount: Int + /// ISO 4217 currency code (e.g. `"USD"`). + public var currency: String + /// Billing interval for recurring prices, or `nil` for one-time purchases. + public var recurringInterval: RecurringInterval? + + public init(unitAmount: Int, currency: String, recurringInterval: RecurringInterval?) { + self.unitAmount = unitAmount + self.currency = currency + self.recurringInterval = recurringInterval + } +} + +extension Price { + + /// The decimal representation of the cost of the product in ``currency``. + public var decimalAmount: Decimal { + Decimal(unitAmount) / pow(Decimal(10), Self.fractionDigits(for: currency)) + } + + /// The localized string representation of the product price, suitable for display. + public var displayPrice: String { + decimalAmount.formatted(.currency(code: currency)) + } + + /// Minor-unit exponent for `currency` (2 for USD, 0 for JPY, 3 for BHD), + private static func fractionDigits(for currency: String) -> Int { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = currency + return formatter.maximumFractionDigits + } + +} + +extension Price: CustomStringConvertible { + + public var description: String { + displayPrice + } + +} diff --git a/Sources/AmoreStore/Models/Product.swift b/Sources/AmoreStore/Models/Product.swift new file mode 100644 index 0000000..4b776dd --- /dev/null +++ b/Sources/AmoreStore/Models/Product.swift @@ -0,0 +1,60 @@ +import Foundation + +/// A product offered by an Amore-licensed application. +public struct Product: Identifiable, Hashable, Codable, Sendable { + /// Server identifier for this product. + public var id: UUID + /// Display name configured by the app owner. + public var name: String + /// License duration in seconds, or `nil` for non-expiring licenses. + public var durationInSeconds: Int? + /// Maximum number of devices that can activate a single license for this product. + public var deviceLimit: Int + /// Pricing information, or `nil` when no price is configured. + public var price: Price? + + /// The checkout URL for this product + /// + /// Open it to send the customer to checkout, for example: + /// ```swift + /// NSWorkspace.shared.open(product.checkoutURL) + /// ``` + public var checkoutURL: URL + + public init( + id: UUID, + name: String, + durationInSeconds: Int?, + deviceLimit: Int, + price: Price?, + checkoutURL: URL + ) { + self.id = id + self.name = name + self.durationInSeconds = durationInSeconds + self.deviceLimit = deviceLimit + self.price = price + self.checkoutURL = checkoutURL + } +} + +public extension Product { + + /// The localized string representation of the product price, suitable for display. + var displayPrice: String? { + price?.displayPrice + } + +} + +extension Product: CustomStringConvertible { + + public var description: String { + if let displayPrice { + "\(name) (\(id)): \(displayPrice)" + } else { + "\(name) (\(id))" + } + } + +} diff --git a/Sources/AmoreStore/Models/RecurringInterval.swift b/Sources/AmoreStore/Models/RecurringInterval.swift new file mode 100644 index 0000000..9680750 --- /dev/null +++ b/Sources/AmoreStore/Models/RecurringInterval.swift @@ -0,0 +1,7 @@ +/// Billing intervals supported by Stripe recurring prices. +public enum RecurringInterval: String, Hashable, Codable, Sendable { + case day + case week + case month + case year +} diff --git a/Sources/AmoreStore/ProductsClient.swift b/Sources/AmoreStore/ProductsClient.swift new file mode 100644 index 0000000..4926339 --- /dev/null +++ b/Sources/AmoreStore/ProductsClient.swift @@ -0,0 +1,5 @@ +import Foundation + +protocol ProductsClient: Sendable { + func fetchProducts(bundleIdentifier: String) async throws -> [Product] +} diff --git a/Sources/AmoreStore/StoreError.swift b/Sources/AmoreStore/StoreError.swift new file mode 100644 index 0000000..15e4adf --- /dev/null +++ b/Sources/AmoreStore/StoreError.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Errors thrown by ``AmoreStore/products()``. +public enum StoreError: LocalizedError, Equatable, Sendable { + /// The network request failed (offline, timeout, or an unexpected transport error). + case network(String) + /// The server rejected the request because too many were made in a short window. + case rateLimited + /// No app matches the configured bundle identifier. + case appNotFound + /// The server returned an unexpected status code. + case serverError(statusCode: Int) + + public var errorDescription: String? { + switch self { + case .network(let message): message + case .rateLimited: "Too many requests. Please try again later." + case .appNotFound: "App not found." + case .serverError(let statusCode): "The server returned an unexpected response (\(statusCode))." + } + } +} diff --git a/Tests/AmoreStoreTests/AmoreStoreTests.swift b/Tests/AmoreStoreTests/AmoreStoreTests.swift new file mode 100644 index 0000000..0d712d8 --- /dev/null +++ b/Tests/AmoreStoreTests/AmoreStoreTests.swift @@ -0,0 +1,85 @@ +import Foundation +import Testing + +@testable import AmoreStore + +@Suite struct AmoreStoreTests { + private let bundleId = "com.test.amorekit" + + private func makeStore(client: MockProductsClient = MockProductsClient()) -> (AmoreStore, MockProductsClient) { + (AmoreStore(bundleIdentifier: bundleId, productsClient: client), client) + } + + @Test func returnsParsedProducts() async throws { + let (store, mock) = makeStore() + let expected = Product( + id: UUID(), + name: "Pro", + durationInSeconds: nil, + deviceLimit: 3, + price: Price(unitAmount: 999, currency: "usd", recurringInterval: .month), + checkoutURL: URL(string: "https://api.amore.computer/v1/checkout/\(UUID())")! + ) + mock.onFetch = { id in + #expect(id == "com.test.amorekit") + return [expected] + } + + let result = try await store.products() + + #expect(result == [expected]) + #expect(result.first?.checkoutURL == expected.checkoutURL) + } + + @Test func mapsClientErrorOnProducts() async throws { + let (store, mock) = makeStore() + mock.onFetch = { _ in throw StoreError.appNotFound } + + await #expect(throws: StoreError.appNotFound) { + try await store.products() + } + } + + @Test func wrapsUnknownErrorOnProducts() async throws { + let (store, mock) = makeStore() + mock.onFetch = { _ in throw URLError(.notConnectedToInternet) } + + await #expect(throws: StoreError.self) { + try await store.products() + } + } + + @Test func decodesServerJSON() throws { + let json = """ + [ + { + "id": "11111111-1111-1111-1111-111111111111", + "name": "Pro", + "durationInSeconds": null, + "deviceLimit": 3, + "price": { "unitAmount": 999, "currency": "usd", "recurringInterval": "month" }, + "checkoutURL": "https://api.amore.computer/v1/checkout/11111111-1111-1111-1111-111111111111" + }, + { + "id": "22222222-2222-2222-2222-222222222222", + "name": "Lite", + "durationInSeconds": 2592000, + "deviceLimit": 1, + "price": null, + "checkoutURL": "https://api.amore.computer/v1/checkout/22222222-2222-2222-2222-222222222222" + } + ] + """ + let decoded = try JSONDecoder().decode([Product].self, from: Data(json.utf8)) + + #expect(decoded.count == 2) + #expect(decoded[0].name == "Pro") + #expect(decoded[0].price?.unitAmount == 999) + #expect(decoded[0].price?.recurringInterval == .month) + #expect(decoded[0].checkoutURL == URL(string: "https://api.amore.computer/v1/checkout/11111111-1111-1111-1111-111111111111")) + #expect(decoded[1].name == "Lite") + #expect(decoded[1].price == nil) + #expect(decoded[1].durationInSeconds == 2_592_000) + #expect(decoded[1].checkoutURL == URL(string: "https://api.amore.computer/v1/checkout/22222222-2222-2222-2222-222222222222")) + } +} diff --git a/Tests/AmoreStoreTests/MockProductsClient.swift b/Tests/AmoreStoreTests/MockProductsClient.swift new file mode 100644 index 0000000..1ce461a --- /dev/null +++ b/Tests/AmoreStoreTests/MockProductsClient.swift @@ -0,0 +1,12 @@ +import Foundation + +@testable import AmoreStore + +final class MockProductsClient: ProductsClient, @unchecked Sendable { + var onFetch: ((String) async throws -> [Product])? + + func fetchProducts(bundleIdentifier: String) async throws -> [Product] { + guard let handler = onFetch else { return [] } + return try await handler(bundleIdentifier) + } +} diff --git a/Tests/AmoreStoreTests/PriceTests.swift b/Tests/AmoreStoreTests/PriceTests.swift new file mode 100644 index 0000000..58deed5 --- /dev/null +++ b/Tests/AmoreStoreTests/PriceTests.swift @@ -0,0 +1,57 @@ +import Foundation +import Testing + +@testable import AmoreStore + +@Suite struct PriceTests { + + @Test func decimalAmountForTwoDecimalCurrency() { + let price = Price(unitAmount: 999, currency: "USD", recurringInterval: .month) + #expect(price.decimalAmount == Decimal(string: "9.99")) + } + + @Test func decimalAmountForZeroDecimalCurrency() { + // JPY has no minor unit: 1000 yen is 1000, not 10.00. + let price = Price(unitAmount: 1000, currency: "JPY", recurringInterval: nil) + #expect(price.decimalAmount == Decimal(1000)) + } + + @Test func decimalAmountForThreeDecimalCurrency() { + // BHD has three minor-unit digits: 1234 fils is 1.234 dinar. + let price = Price(unitAmount: 1234, currency: "BHD", recurringInterval: nil) + #expect(price.decimalAmount == Decimal(string: "1.234")) + } + + @Test func displayPriceUsesCurrencyFormatting() { + let price = Price(unitAmount: 999, currency: "USD", recurringInterval: .month) + // Locale-independent checks: the formatted amount and a currency marker are present. + #expect(price.displayPrice.contains("9")) + #expect(price.displayPrice.contains("99")) + #expect(!price.displayPrice.isEmpty) + } + + @Test func productDisplayPriceDelegatesToPrice() { + let priced = Product( + id: UUID(), + name: "Pro", + durationInSeconds: nil, + deviceLimit: 3, + price: Price(unitAmount: 999, currency: "USD", recurringInterval: .month), + checkoutURL: URL(string: "https://api.amore.computer/v1/checkout/\(UUID())")! + ) + #expect(priced.displayPrice == priced.price?.displayPrice) + #expect(priced.displayPrice != nil) + } + + @Test func productDisplayPriceIsNilWithoutPrice() { + let free = Product( + id: UUID(), + name: "Lite", + durationInSeconds: 2_592_000, + deviceLimit: 1, + price: nil, + checkoutURL: URL(string: "https://api.amore.computer/v1/checkout/\(UUID())")! + ) + #expect(free.displayPrice == nil) + } +}