Skip to content

Commit c32b8b8

Browse files
authored
Merge pull request #4 from AmoreComputer/feat/AmoreStore
Add AmoreStore target to fetch products including prices
2 parents a877bf9 + c75a5bf commit c32b8b8

16 files changed

Lines changed: 532 additions & 2 deletions

‎Makefile‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ docs:
66
--disable-indexing \
77
--transform-for-static-hosting \
88
--target AmoreLicensing \
9-
--output-path ./docs
10-
# --enable-experimental-combined-documentation \
9+
--target AmoreStore \
10+
--output-path ./docs \
11+
--enable-experimental-combined-documentation \
1112

1213
docs-preview:
1314
swift package --disable-sandbox preview-documentation --target AmoreLicensing

‎Package.swift‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ let package = Package(
1212
name: "AmoreLicensing",
1313
targets: ["AmoreLicensing"]
1414
),
15+
.library(
16+
name: "AmoreStore",
17+
targets: ["AmoreStore"]
18+
),
1519
],
1620
dependencies: [
1721
.package(url: "https://github.com/vapor/jwt-kit.git", from: "5.0.0"),
@@ -24,13 +28,18 @@ let package = Package(
2428
.product(name: "JWTKit", package: "jwt-kit"),
2529
],
2630
),
31+
.target(name: "AmoreStore"),
2732
.testTarget(
2833
name: "AmoreLicensingTests",
2934
dependencies: [
3035
"AmoreLicensing",
3136
.product(name: "JWTKit", package: "jwt-kit"),
3237
]
3338
),
39+
.testTarget(
40+
name: "AmoreStoreTests",
41+
dependencies: ["AmoreStore"]
42+
),
3443
],
3544
swiftLanguageModes: [.v6]
3645
)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import Foundation
2+
3+
extension URL {
4+
/// Default Amore licensing server. Internal to `AmoreStore` so it does not
5+
/// collide with the public `URL.amoreServer` declared in `AmoreLicensing`.
6+
static let amoreServer = URL(string: "https://api.amore.computer")!
7+
}
8+
9+
/// Lists the products configured for an Amore-licensed app.
10+
///
11+
/// Fetch products with ``products()``, then read ``Product/checkoutURL`` on a
12+
/// returned product to send a customer to Stripe checkout. Use this to build
13+
/// paywalls, pickers, or any view that displays the products and prices
14+
/// configured in the licensing dashboard.
15+
public struct AmoreStore: Sendable {
16+
private let bundleIdentifier: String
17+
private let productsClient: ProductsClient
18+
19+
/// Creates a store client for the given bundle identifier, targeting the Amore server.
20+
/// - Parameter bundleIdentifier: The app's bundle identifier. Defaults to `Bundle.main.bundleIdentifier`.
21+
public init(bundleIdentifier: String? = nil) {
22+
self.init(bundleIdentifier: bundleIdentifier, baseURL: .amoreServer)
23+
}
24+
25+
/// Creates a store client for the given bundle identifier and server URL.
26+
/// - Parameters:
27+
/// - bundleIdentifier: The app's bundle identifier. Defaults to `Bundle.main.bundleIdentifier`.
28+
/// - baseURL: The licensing server base URL.
29+
public init(bundleIdentifier: String? = nil, baseURL: URL) {
30+
self.bundleIdentifier = bundleIdentifier ?? Bundle.main.bundleIdentifier ?? ""
31+
self.productsClient = HTTPProductsClient(baseURL: baseURL)
32+
}
33+
34+
init(bundleIdentifier: String, productsClient: ProductsClient) {
35+
self.bundleIdentifier = bundleIdentifier
36+
self.productsClient = productsClient
37+
}
38+
39+
/// Returns the products configured for this app. Purchasable products carry a
40+
/// non-`nil` ``Product/checkoutURL``.
41+
/// - Throws: ``StoreError`` if the request fails.
42+
public func products() async throws(StoreError) -> [Product] {
43+
do {
44+
return try await productsClient.fetchProducts(bundleIdentifier: bundleIdentifier)
45+
} catch let error as StoreError {
46+
throw error
47+
} catch {
48+
throw .network(error.localizedDescription)
49+
}
50+
}
51+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Getting Started
2+
3+
This article describes how to get started with AmoreStore.
4+
5+
## Installation
6+
7+
In Xcode, go to **File → Add Package Dependencies…** and enter:
8+
9+
```
10+
https://github.com/AmoreComputer/AmoreKit
11+
```
12+
13+
Or add it to your `Package.swift`:
14+
15+
```swift
16+
.package(url: "https://github.com/AmoreComputer/AmoreKit", from: "0.1")
17+
```
18+
19+
## AmoreStore
20+
21+
To get started with AmoreStore, create an instance of ``AmoreStore``. By default it uses your app's `Bundle.main.bundleIdentifier`.
22+
23+
```swift
24+
let store = AmoreStore()
25+
```
26+
27+
## Fetching Products
28+
29+
Call ``AmoreStore/products()`` to fetch the products configured for your app.
30+
31+
```swift
32+
let products = try await store.products()
33+
```
34+
35+
> Note: ``AmoreStore/products()`` throws ``StoreError`` with detailed information about what went wrong.
36+
37+
## Displaying Prices
38+
39+
Each ``Product`` carries an optional ``Product/price`` with a localized, display-ready string.
40+
41+
```swift
42+
ForEach(products) { product in
43+
HStack {
44+
Text(product.name)
45+
Spacer()
46+
if let displayPrice = product.displayPrice {
47+
Text(displayPrice)
48+
}
49+
}
50+
}
51+
```
52+
53+
Use ``Price/recurringInterval`` to tell one-time purchases from subscriptions.
54+
55+
## Checkout
56+
57+
Every purchasable ``Product`` carries a ``Product/checkoutURL``. Open it to send the customer to Stripe checkout.
58+
59+
```swift
60+
NSWorkspace.shared.open(product.checkoutURL)
61+
```
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# ``AmoreStore``
2+
3+
A macOS SDK for listing an app's products and sending customers to checkout.
4+
5+
## Overview
6+
7+
AmoreStore is the store SDK for [Amore](https://amore.computer).
8+
9+
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.
10+
11+
## Topics
12+
13+
### Articles
14+
15+
- <doc:Getting-Started>
16+
17+
### Essentials
18+
19+
- ``AmoreStore``
20+
- ``Product``
21+
- ``Price``
22+
- ``RecurringInterval``
23+
24+
### Errors
25+
26+
- ``StoreError``
Lines changed: 6 additions & 0 deletions
Loading
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"theme": {
3+
"aside": {
4+
"border-radius": "6px",
5+
"border-style": "solid",
6+
"border-width": "2px"
7+
},
8+
"border-radius": "0",
9+
"button": {
10+
"border-radius": "16px",
11+
"border-width": "1px",
12+
"border-style": "solid"
13+
},
14+
"code": {
15+
"border-radius": "16px",
16+
"border-width": "1px",
17+
"border-style": "solid"
18+
},
19+
"color": {
20+
"amore": "#f09000",
21+
"documentation-intro-fill": { "dark": "#000", "light": "#fff" },
22+
"documentation-intro-accent": "var(--color-jwtkit)",
23+
"logo-base": { "dark": "#fff", "light": "#000" },
24+
"logo-shape": { "dark": "#000", "light": "#fff" },
25+
"link": { "dark": "var(--color-amore)", "light": "var(--color-amore)" },
26+
"fill": { "dark": "#222", "light": "#eee" },
27+
"button-background": "var(--color-amore)",
28+
"fill-blue": "var(--color-amore)",
29+
"figure-blue": "var(--color-amore)"
30+
},
31+
"icons": {
32+
"technology": "/images/AmoreStore/heart.svg"
33+
}
34+
}
35+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import Foundation
2+
3+
struct HTTPProductsClient: ProductsClient {
4+
private let baseURL: URL
5+
6+
init(baseURL: URL) {
7+
self.baseURL = baseURL
8+
}
9+
10+
func fetchProducts(bundleIdentifier: String) async throws -> [Product] {
11+
let url = baseURL
12+
.appendingPathComponent("v1/public/apps")
13+
.appendingPathComponent(bundleIdentifier)
14+
.appendingPathComponent("products")
15+
16+
var request = URLRequest(url: url)
17+
request.httpMethod = "GET"
18+
request.setValue("application/json", forHTTPHeaderField: "Accept")
19+
20+
let data: Data
21+
let response: URLResponse
22+
do {
23+
(data, response) = try await URLSession.shared.data(for: request)
24+
} catch {
25+
throw StoreError.network(error.localizedDescription)
26+
}
27+
28+
guard let http = response as? HTTPURLResponse else {
29+
throw StoreError.network("Invalid response from server")
30+
}
31+
switch http.statusCode {
32+
case 200...299:
33+
do {
34+
return try JSONDecoder().decode([Product].self, from: data)
35+
} catch {
36+
throw StoreError.network("Could not decode product list: \(error.localizedDescription)")
37+
}
38+
case 404:
39+
throw StoreError.appNotFound
40+
case 429:
41+
throw StoreError.rateLimited
42+
default:
43+
throw StoreError.serverError(statusCode: http.statusCode)
44+
}
45+
}
46+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import Foundation
2+
3+
/// Pricing information for a product as configured in Stripe.
4+
public struct Price: Hashable, Codable, Sendable {
5+
/// Amount in the smallest unit of `currency` (e.g. cents for USD, yen for JPY).
6+
public var unitAmount: Int
7+
/// ISO 4217 currency code (e.g. `"USD"`).
8+
public var currency: String
9+
/// Billing interval for recurring prices, or `nil` for one-time purchases.
10+
public var recurringInterval: RecurringInterval?
11+
12+
public init(unitAmount: Int, currency: String, recurringInterval: RecurringInterval?) {
13+
self.unitAmount = unitAmount
14+
self.currency = currency
15+
self.recurringInterval = recurringInterval
16+
}
17+
}
18+
19+
extension Price {
20+
21+
/// The decimal representation of the cost of the product in ``currency``.
22+
public var decimalAmount: Decimal {
23+
Decimal(unitAmount) / pow(Decimal(10), Self.fractionDigits(for: currency))
24+
}
25+
26+
/// The localized string representation of the product price, suitable for display.
27+
public var displayPrice: String {
28+
decimalAmount.formatted(.currency(code: currency))
29+
}
30+
31+
/// Minor-unit exponent for `currency` (2 for USD, 0 for JPY, 3 for BHD),
32+
private static func fractionDigits(for currency: String) -> Int {
33+
let formatter = NumberFormatter()
34+
formatter.numberStyle = .currency
35+
formatter.currencyCode = currency
36+
return formatter.maximumFractionDigits
37+
}
38+
39+
}
40+
41+
extension Price: CustomStringConvertible {
42+
43+
public var description: String {
44+
displayPrice
45+
}
46+
47+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import Foundation
2+
3+
/// A product offered by an Amore-licensed application.
4+
public struct Product: Identifiable, Hashable, Codable, Sendable {
5+
/// Server identifier for this product.
6+
public var id: UUID
7+
/// Display name configured by the app owner.
8+
public var name: String
9+
/// License duration in seconds, or `nil` for non-expiring licenses.
10+
public var durationInSeconds: Int?
11+
/// Maximum number of devices that can activate a single license for this product.
12+
public var deviceLimit: Int
13+
/// Pricing information, or `nil` when no price is configured.
14+
public var price: Price?
15+
16+
/// The checkout URL for this product
17+
///
18+
/// Open it to send the customer to checkout, for example:
19+
/// ```swift
20+
/// NSWorkspace.shared.open(product.checkoutURL)
21+
/// ```
22+
public var checkoutURL: URL
23+
24+
public init(
25+
id: UUID,
26+
name: String,
27+
durationInSeconds: Int?,
28+
deviceLimit: Int,
29+
price: Price?,
30+
checkoutURL: URL
31+
) {
32+
self.id = id
33+
self.name = name
34+
self.durationInSeconds = durationInSeconds
35+
self.deviceLimit = deviceLimit
36+
self.price = price
37+
self.checkoutURL = checkoutURL
38+
}
39+
}
40+
41+
public extension Product {
42+
43+
/// The localized string representation of the product price, suitable for display.
44+
var displayPrice: String? {
45+
price?.displayPrice
46+
}
47+
48+
}
49+
50+
extension Product: CustomStringConvertible {
51+
52+
public var description: String {
53+
if let displayPrice {
54+
"\(name) (\(id)): \(displayPrice)"
55+
} else {
56+
"\(name) (\(id))"
57+
}
58+
}
59+
60+
}

0 commit comments

Comments
 (0)