diff --git a/CHANGELOG.md b/CHANGELOG.md index 127e242..3378827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ CHANGELOG ========= +Mercato 1.1.0 +--------- + +### Added +* Advanced Commerce API support + - `AdvancedCommerceMercato` module for working with Advanced Commerce API + - Request models for `OneTimeChargeCreateRequest`, `SubscriptionCreateRequest`, `SubscriptionModifyInAppRequest`, and `SubscriptionReactivateInAppRequest` + Mercato 1.0.1 --------- diff --git a/Documentation/AdvancedCommerce.md b/Documentation/AdvancedCommerce.md new file mode 100644 index 0000000..bd9399b --- /dev/null +++ b/Documentation/AdvancedCommerce.md @@ -0,0 +1,530 @@ +# Advanced Commerce API Guide + +Use the Advanced Commerce API to more easily support exceptionally large content catalogs, creator experiences, and subscriptions with optional add-ons offered within your apps. +> This feature is available on iOS 18.4+, macOS 15.4+, tvOS 18.4+, watchOS 11.4+, and visionOS 2.4+. + +## Overview + +Advanced Commerce allows you to make certain API requests through StoreKit in your app, while other requests are made directly from your server. Both approaches use signatures generated on your server for authorization. + + +## Installation + +The Advanced Commerce functionality is provided in a separate module: + +```swift +import AdvancedCommerceMercato +``` + +## Available Methods + +The `AdvancedCommerceMercato` class provides the following methods: + +### Product Retrieval +- **`retrieveProducts(productIds:)`** - Retrieve Advanced Commerce products by their IDs + ```swift + let products = try await AdvancedCommerceMercato.shared.retrieveProducts( + productIds: ["com.app.premium", "com.app.basic"] + ) + ``` + +### Purchase Methods +- **`purchase(productId:compactJWS:confirmIn:options:)`** - Purchase using Advanced Commerce with JWS (non-watchOS) + ```swift + let purchase = try await AdvancedCommerceMercato.purchase( + productId: "com.app.premium", + compactJWS: signedJWS, + confirmIn: viewController, // UIViewController on iOS, NSWindow on macOS + options: [] + ) + ``` + +- **`purchase(productId:compactJWS:options:)`** - Purchase using Advanced Commerce with JWS (watchOS only) + ```swift + let purchase = try await AdvancedCommerceMercato.purchase( + productId: "com.app.premium", + compactJWS: signedJWS, + options: [] + ) + ``` + +### Transaction Management +- **`allTransactions(for:)`** - Get all transactions for a product ID +- **`currentEntitlements(for:)`** - Get current entitlements for a product ID +- **`latestTransaction(for:)`** - Get the latest transaction for a product ID + +All static methods are also available as instance methods on `AdvancedCommerceMercato.shared`. + +## Sending Advanced Commerce API requests from your app + +The following Advanced Commerce API requests are available through StoreKit: + +- **OneTimeChargeCreateRequest** - Create one-time purchases with custom pricing +- **SubscriptionCreateRequest** - Create subscriptions with flexible billing terms +- **SubscriptionModifyInAppRequest** - Modify existing subscriptions +- **SubscriptionReactivateInAppRequest** - Reactivate expired subscriptions + +### 1. Create the base64-encoded request data in your app + +Place the Advanced Commerce request data in a UTF-8 JSON string and base64-encode the request. +For example, the following JSON represents a `OneTimeChargeCreateRequest` for the purchase of a one-time charge product: + +```json +{ + "operation": "CREATE_ONE_TIME_CHARGE", + "version": "1", + "requestInfo": { + "requestReferenceId": "f55df048-4cd8-4261-b404-b6f813ff70e5" + }, + "currency": "USD", + "taxCode": "C003-00-2", + "storefront": "USA", + "item": { + "SKU": "BOOK_SHERLOCK_HOMLES", + "displayName": "Sherlock Holmes", + "description": "The Sherlock Holmes, 5th Edition", + "price": 4990 + } +} +``` + +#### Example: +To create this object use OneTimeChargeCreateRequest model or any other request objects: +```swift +func encodeRequestToBase64(_ request: Codable) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + + let jsonData = try encoder.encode(request) + return jsonData.base64EncodedString() +} + +let request = OneTimeChargeCreateRequest(...) +let base64encodedRequest = encodeRequestToBase64(request) +``` + +Base64-encode this JSON to create your request data. + +### 2. Server-Side: Generate the JWS using your request data + +Follow the [signing instructions for Advanced Commerce API](https://developer.apple.com/documentation/storekit/generating-jws-to-sign-app-store-requests) in-app requests in Generating JWS to sign App Store requests. The Advanced Commerce API requires a custom claim, request. Provide the base64-encoded request data from the previous step as the value of the request claim in the JWS payload. +The result after following the instructions is a JWS compact serialization. + +### 3. Server-Side: Wrap the JWS and convert it into data + +Wrap the JWS to create a signatureInfo JSON string that contains a token key. You can complete this step on your server or in your app. Create the signatureInfo JSON string as shown below: + +```json +{ + "signatureInfo": { + "token": "" + } +} +``` + +- Set the value of the token key to your JWS compact serialization. +- Next, convert the signatureInfo JSON string into a Data buffer, as shown below: + +``` +// Could be different api for different languages but the idea is the same +let jsonString ="<# your signatureInfo UTF-8 JSON string>" +let advancedCommerceRequestData = Data(jsonString.utf8) +``` + +The result is the Advanced Commerce request data object, referred to as `advancedCommerceRequestData in the code snippets. +Securely send the advancedCommerceRequestData to your app. + +### 4. App-Side: Call the StoreKit purchase API using the signed request data + +To complete the Advanced Commerce request in the app, call a AdvancedCommerceMercato purchase method and provide product id and the signed request. + +```swift +import AdvancedCommerceMercato + +let purchase = try await AdvancedCommerceMercato.purchase( + productId: "com.app.premium", + advancedCommerceData: signedJWS +) + +``` + +## Request Models + +Mercato provides Swift models for all supported request types: + +### Request Validation + +All Advanced Commerce request models conform to the `Validatable` protocol and include a `validate()` method that checks for valid parameters before sending to your server. This helps catch errors early in the development process: + +```swift +do { + let request = OneTimeChargeCreateRequest( + currency: "USD", + item: OneTimeChargeItem( + sku: "BOOK_SHERLOCK_HOLMES", + displayName: "Sherlock Holmes", + description: "The Sherlock Holmes, 5th Edition", + price: 4990 + ), + requestInfo: RequestInfo(requestReferenceId: UUID().uuidString), + taxCode: "C003-00-2", + storefront: "USA" + ) + + // Validate the request before sending + try request.validate() + + // If validation passes, encode and send to server + let base64Request = try encodeRequestToBase64(request) + // ... send to server + +} catch { + print("Validation failed: \(error)") + // Handle validation errors +} +``` + +The validation checks include: +- **Currency codes** - Validates ISO 4217 currency codes +- **Tax codes** - Ensures proper tax code format +- **Storefront codes** - Validates country/region codes +- **Transaction IDs** - Checks transaction ID format +- **Required fields** - Ensures all required fields are present +- **Nested objects** - Validates all nested items and descriptors + +It's recommended to always validate requests before sending them to your server to ensure data integrity and prevent server-side errors. + +### OneTimeChargeCreateRequest + +```swift +let request = OneTimeChargeCreateRequest( + currency: "USD", + item: OneTimeChargeItem( + sku: "BOOK_SHERLOCK_HOLMES", + displayName: "Sherlock Holmes", + description: "The Sherlock Holmes, 5th Edition", + price: 4990 + ), + requestInfo: RequestInfo(requestReferenceId: UUID().uuidString), + taxCode: "C003-00-2", + storefront: "USA" +) +``` + +### SubscriptionCreateRequest + +```swift +let request = SubscriptionCreateRequest( + currency: "USD", + descriptors: Descriptors( + displayName: "Premium Subscription", + description: "Access to all premium features" + ), + items: [ + SubscriptionCreateItem( + sku: "PREMIUM_MONTHLY", + displayName: "Premium Monthly", + description: "Monthly subscription", + price: 999 + ) + ], + period: Period(value: 1, unit: .month), + previousTransactionId: nil, // Optional: for upgrades/downgrades + requestInfo: RequestInfo(requestReferenceId: UUID().uuidString), + storefront: "USA", + taxCode: "C003-00-2" +) +``` + +### SubscriptionModifyInAppRequest + +```swift +let request = SubscriptionModifyInAppRequest( + requestInfo: RequestInfo(requestReferenceId: UUID().uuidString), + addItems: [ + SubscriptionModifyAddItem( + sku: "PREMIUM_ADDON", + displayName: "Premium Add-on", + description: "Additional features", + price: 299 + ) + ], + changeItems: nil, // Optional: items to modify + removeItems: nil, // Optional: items to remove + currency: "USD", + descriptors: nil, // Optional: update subscription descriptors + periodChange: nil, // Optional: change billing period + retainBillingCycle: true, + storefront: "USA", + taxCode: "C003-00-2", + transactionId: "1234567890" // Required: existing transaction ID +) + +// Alternative: Using builder pattern +let request = SubscriptionModifyInAppRequest( + requestInfo: RequestInfo(requestReferenceId: UUID().uuidString), + retainBillingCycle: true, + transactionId: "1234567890" +) +.addAddItem( + SubscriptionModifyAddItem( + sku: "PREMIUM_ADDON", + displayName: "Premium Add-on", + description: "Additional features", + price: 299 + ) +) +.currency("USD") +.storefront("USA") +.taxCode("C003-00-2") +``` + +### SubscriptionReactivateInAppRequest + +```swift +let request = SubscriptionReactivateInAppRequest( + requestInfo: RequestInfo(requestReferenceId: UUID().uuidString), + items: [ + SubscriptionReactivateItem( + sku: "PREMIUM_MONTHLY", + displayName: "Premium Monthly", + description: "Monthly subscription", + price: 999 + ) + ], + transactionId: "1234567890", // Required: previous transaction ID + storefront: "USA" +) + +// Alternative: Using builder pattern +let request = SubscriptionReactivateInAppRequest( + requestInfo: RequestInfo(requestReferenceId: UUID().uuidString), + transactionId: "1234567890" +) +.addItem( + SubscriptionReactivateItem( + sku: "PREMIUM_MONTHLY", + displayName: "Premium Monthly", + description: "Monthly subscription", + price: 999 + ) +) +.storefront("USA") +``` + +## Transaction Management + +Advanced Commerce purchases integrate with Mercato's transaction monitoring: + +```swift +// Get all transactions for a product +let transactions = await AdvancedCommerceMercato.allTransactions(for: productId) + +// Get current entitlements +let entitlements = await AdvancedCommerceMercato.currentEntitlements(for: productId) + +// Get latest transaction +let latest = await AdvancedCommerceMercato.latestTransaction(for: productId) +``` + +## Error Handling + +Advanced Commerce operations throw `MercatoError`: + +```swift +do { + let purchase = try await AdvancedCommerceMercato.purchase( + productId: productId, + compactJWS: jws, + confirmIn: view + ) +} catch MercatoError.productNotFound { + // Product ID not found +} catch MercatoError.canceledByUser { + // User canceled the purchase +} catch MercatoError.purchaseIsPending { + // Purchase requires approval (Ask to Buy) +} catch { + // Handle other errors +} +``` + +## Complete Examples + +### Example 1: StoreKit Purchase with Advanced Commerce Data + +This approach uses StoreKit's standard purchase flow with server-provided Advanced Commerce data: + +```swift +import AdvancedCommerceMercato + +class PurchaseManager { + func encodeRequestToBase64(_ request: Codable) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + + let jsonData = try encoder.encode(request) + return jsonData.base64EncodedString() + } + + func purchaseWithAdvancedCommerce(productId: String) async throws { + // 0. Build request data + let request = OneTimeChargeCreateRequest( + currency: "USD", + item: OneTimeChargeItem( + sku: "BOOK_SHERLOCK_HOLMES", + displayName: "Sherlock Holmes", + description: "The Sherlock Holmes, 5th Edition", + price: 4990 + ), + requestInfo: RequestInfo(requestReferenceId: UUID().uuidString), + taxCode: "C003-00-2", + storefront: "USA" + ) + let base64encodedRequest = encodeRequestToBase64(request) + + // 1. Get Advanced Commerce data from your server + let advancedCommerceData = try await fetchAdvancedCommerceDataFromServer( + for: base64encodedRequest + ) + + // 2. Make the purchase through StoreKit + let result = try await AdvancedCommerceMercato.purchase( + productId: productId, + advancedCommerceData: advancedCommerceData + ) + + // 3. Handle the result + switch result { + case .success(let verification): + let transaction = try verification.payload + + // Deliver the content + await deliverContent(for: transaction.productID) + + // Finish the transaction + await transaction.finish() + + case .userCancelled: + print("Purchase cancelled by user") + + case .pending: + print("Purchase pending approval") + + @unknown default: + print("Unknown purchase result") + } + } +} +``` + +### Example 2: Direct Advanced Commerce Purchase + +This approach uses Advanced Commerce products with full control over the purchase flow: + +```swift +import AdvancedCommerceMercato + +class AdvancedPurchaseManager { + func purchaseAdvancedCommerceProduct(productId: String) async throws { + // 1. Get signed JWS from your server + let compactJWS = try await fetchSignedJWSFromServer( + for: productId, + operation: .createOneTimeCharge + ) + + // 2. Make the Advanced Commerce purchase + #if os(watchOS) + let purchase = try await AdvancedCommerceMercato.purchase( + productId: productId, + compactJWS: compactJWS, + options: [] + ) + #else + let purchase = try await AdvancedCommerceMercato.purchase( + productId: productId, + compactJWS: compactJWS, + confirmIn: getCurrentUIContext(), + options: [] + ) + #endif + + // 3. Handle the purchase + await handlePurchase(purchase) + } + + private func handlePurchase(_ purchase: AdvancedCommercePurchase) async { + // Deliver content + await deliverContent(for: purchase.productId) + + // Check if transaction needs finishing + if purchase.needsFinishTransaction { + await purchase.finish() + } + + // Verify the purchase with your server + await verifyWithServer(transaction: purchase.transaction) + } + + #if !os(watchOS) + private func getCurrentUIContext() -> PurchaseUIContext { + #if os(iOS) + // Return current UIViewController + return UIApplication.shared.keyWindow?.rootViewController ?? UIViewController() + #elseif os(macOS) + // Return current NSWindow + return NSApplication.shared.mainWindow ?? NSWindow() + #endif + } + #endif +} +``` + +### Example 3: Complete Transaction Management + +```swift +import AdvancedCommerceMercato + +class TransactionManager { + func checkUserEntitlements() async { + let productIds = ["com.app.premium", "com.app.basic"] + + for productId in productIds { + // Check current entitlements + if let entitlements = await AdvancedCommerceMercato.currentEntitlements(for: productId) { + for await transaction in entitlements { + print("Active entitlement: \(transaction.productID)") + // Update UI based on active subscriptions + } + } + + // Get latest transaction + if let latest = await AdvancedCommerceMercato.latestTransaction(for: productId) { + switch latest { + case .verified(let transaction): + print("Latest verified transaction: \(transaction.id)") + case .unverified(let transaction, let error): + print("Unverified transaction: \(error)") + } + } + } + } + + func getAllTransactionHistory(for productId: String) async { + guard let transactions = await AdvancedCommerceMercato.allTransactions(for: productId) else { + return + } + + for await transaction in transactions { + print("Transaction: \(transaction.id), Date: \(transaction.purchaseDate)") + } + } +} +``` + +## Further Resources + +- [Apple's Advanced Commerce API Documentation](https://developer.apple.com/documentation/advancedcommerceapi) +- [Sending Advanced Commerce API requests from your app](https://developer.apple.com/documentation/storekit/sending-advanced-commerce-api-requests-from-your-app) +- [Generating JWS for App Store Requests](https://developer.apple.com/documentation/appstoreserverapi/generating_tokens_for_api_requests) +- [StoreKit 2 Documentation](https://developer.apple.com/documentation/storekit) diff --git a/Documentation/Usage.md b/Documentation/Usage.md index 398bb6e..b83d64d 100644 --- a/Documentation/Usage.md +++ b/Documentation/Usage.md @@ -560,6 +560,12 @@ func showSubscriptionManagement(in scene: UIWindowScene) async { ## Advanced Features +### Advanced Commerce API Support + +Mercato provides support for Apple's Advanced Commerce API. The Advanced Commerce functionality is available through a separate `AdvancedCommerceMercato` module. + +For detailed implementation guidance and examples, see the [Advanced Commerce API Guide](AdvancedCommerce.md). + ### Price and Period Formatters Mercato includes built-in formatters for displaying prices and periods correctly: diff --git a/Package.swift b/Package.swift index 07fdd2f..430beb7 100644 --- a/Package.swift +++ b/Package.swift @@ -33,14 +33,27 @@ let package = Package( .library( name: "Mercato", targets: ["Mercato"] + ), + .library( + name: "AdvancedCommerceMercato", + targets: ["AdvancedCommerceMercato"] ) ], dependencies: [], targets: [ .target(name: "Mercato"), + .target( + name: "AdvancedCommerceMercato", + dependencies: [ + .target(name: "Mercato") + ] + ), .testTarget( name: "MercatoTests", - dependencies: ["Mercato"], + dependencies: [ + .target(name: "Mercato"), + .target(name: "AdvancedCommerceMercato") + ], resources: [ .copy("Mercato.storekit") ] diff --git a/Package@swift-5.10.swift b/Package@swift-5.10.swift index 95484b9..08ddc75 100644 --- a/Package@swift-5.10.swift +++ b/Package@swift-5.10.swift @@ -33,14 +33,27 @@ let package = Package( .library( name: "Mercato", targets: ["Mercato"] + ), + .library( + name: "AdvancedCommerceMercato", + targets: ["AdvancedCommerceMercato"] ) ], dependencies: [], targets: [ .target(name: "Mercato"), + .target( + name: "AdvancedCommerceMercato", + dependencies: [ + .target(name: "Mercato") + ] + ), .testTarget( name: "MercatoTests", - dependencies: ["Mercato"], + dependencies: [ + .target(name: "Mercato"), + .target(name: "AdvancedCommerceMercato") + ], resources: [ .copy("Mercato.storekit") ] diff --git a/README.md b/README.md index bd70d79..6155cd6 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,96 @@ if let result = await Mercato.latest(for: productId) { try await Mercato.syncPurchases() ``` +## Advanced Commerce (iOS 18.4+) + +Mercato includes support for Advanced Commerce API. +> This feature requires iOS 18.4+, macOS 15.4+, tvOS 18.4+, watchOS 11.4+, or visionOS 2.4+. + +### Purchase with Compact JWS + +```swift +import AdvancedCommerceMercato + +// iOS/macOS/tvOS/visionOS - requires UI context +let purchase = try await AdvancedCommerceMercato.purchase( + productId: "com.app.premium", + compactJWS: signedJWS, + confirmIn: viewController // UIViewController on iOS, NSWindow on macOS +) + +// watchOS - no UI context needed +let purchase = try await AdvancedCommerceMercato.purchase( + productId: "com.app.premium", + compactJWS: signedJWS +) +``` + +### Purchase with Advanced Commerce Data + +```swift +// Direct purchase with raw Advanced Commerce data +let result = try await AdvancedCommerceMercato.purchase( + productId: "com.app.premium", + advancedCommerceData: dataFromServer +) +``` + +### Request Validation + +All Advanced Commerce request models include built-in validation to ensure data integrity before sending to your server: + +```swift +import AdvancedCommerceMercato + +// Create a request +let request = OneTimeChargeCreateRequest( + currency: "USD", + item: OneTimeChargeItem( + sku: "BOOK_001", + displayName: "Digital Book", + description: "Premium content", + price: 999 + ), + requestInfo: RequestInfo(requestReferenceId: UUID().uuidString), + taxCode: "C003-00-2" +) + +// Validate before sending to server +do { + try request.validate() + // Request is valid, proceed with encoding and sending +} catch { + print("Validation error: \(error)") +} +``` + +Validation checks include currency codes, tax codes, storefronts, transaction IDs, and required fields. + +### Transaction Management + +```swift +// Get latest transaction for a product +if let result = await AdvancedCommerceMercato.latestTransaction(for: productId) { + let transaction = try result.payload +} + +// Current entitlements +if let entitlements = await AdvancedCommerceMercato.currentEntitlements(for: productId) { + for await result in entitlements { + // Process active subscriptions + } +} + +// All transactions +if let transactions = await AdvancedCommerceMercato.allTransactions(for: productId) { + for await result in transactions { + // Process transaction history + } +} +``` + +For detailed information about implementing Advanced Commerce, including request signing and supported operations, see [AdvancedCommerce.md](Documentation/AdvancedCommerce.md). + ## Documentation See [Usage.md](Documentation/Usage.md) for complete documentation. diff --git a/Sources/AdvancedCommerceMercato/AdvancedCommerceProductService.swift b/Sources/AdvancedCommerceMercato/AdvancedCommerceProductService.swift new file mode 100644 index 0000000..5c8e48e --- /dev/null +++ b/Sources/AdvancedCommerceMercato/AdvancedCommerceProductService.swift @@ -0,0 +1,73 @@ +// +// File.swift +// Mercato +// +// Created by PT on 8/26/25. +// + +import Foundation +import Mercato +import StoreKit + +// MARK: - AdvancedCommerceProductService + +@available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) +public protocol AdvancedCommerceProductService: ProductService where ProductItem == AdvancedCommerceProduct { + func latestTransaction(for productId: AdvancedCommerceProduct.ID) async -> VerificationResult? + func allTransactions(for productId: AdvancedCommerceProduct.ID) async -> Transaction.Transactions? + func currentEntitlements(for productId: AdvancedCommerceProduct.ID) async -> Transaction.Transactions? +} + +@available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) +public typealias AdvancedCommerceCachingProductService = AbstractCachingProductService + +// MARK: - AdvancedCommerceCachingProductService + AdvancedCommerceProductService + +@available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) +extension AdvancedCommerceCachingProductService: AdvancedCommerceProductService { + public func latestTransaction(for productId: AdvancedCommerceProduct.ID) async -> VerificationResult? { + guard let product = try? await retrieveProducts(productIds: [productId]).first else { + return nil + } + + return await product.latestTransaction + } + + public func allTransactions(for productId: AdvancedCommerceProduct.ID) async -> Transaction.Transactions? { + guard let product = try? await retrieveProducts(productIds: [productId]).first else { + return nil + } + + return product.allTransactions + } + + public func currentEntitlements(for productId: AdvancedCommerceProduct.ID) async -> Transaction.Transactions? { + guard let product = try? await retrieveProducts(productIds: [productId]).first else { + return nil + } + + return product.currentEntitlements + } +} + +// MARK: - AdvancedCommerceProduct + FetchableProduct + +@available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) +extension AdvancedCommerceProduct: FetchableProduct { + public static func products(for identifiers: some Collection) async throws -> [AdvancedCommerceProduct] { + try await withThrowingTaskGroup(of: AdvancedCommerceProduct.self) { group in + for id in identifiers { + group.addTask { + try await AdvancedCommerceProduct(id: id) + } + } + + var products: [AdvancedCommerceProduct] = [] + for try await product in group { + products.append(product) + } + + return products + } + } +} diff --git a/Sources/AdvancedCommerceMercato/AdvancedCommercePurchase.swift b/Sources/AdvancedCommerceMercato/AdvancedCommercePurchase.swift new file mode 100644 index 0000000..8b2c6d3 --- /dev/null +++ b/Sources/AdvancedCommerceMercato/AdvancedCommercePurchase.swift @@ -0,0 +1,52 @@ +// +// File.swift +// Mercato +// +// Created by PT on 8/26/25. +// + +import StoreKit + +// MARK: - AdvancedCommercePurchase + +/// A wrapper around StoreKit's `AdvancedCommerceProduct` and `Transaction` objects, providing a convenient interfae for handling in-app purchases. +@available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) +public struct AdvancedCommercePurchase: Sendable { + /// The product associated with the purchase. + public let product: AdvancedCommerceProduct + + /// The result associated with the purchase. + public let result: VerificationResult + + /// A flag indicating whether the transaction needs to be finished manually. + public let needsFinishTransaction: Bool +} + +@available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) +extension AdvancedCommercePurchase { + /// The transaction associated with the purchase. + public var transaction: Transaction { + result.unsafePayloadValue + } + + /// The identifier of the purchased product. + /// + /// This is derived from the `productID` property of the `transaction` object. + public var productId: String { + product.id + } + + /// The quantity of the purchased product. + /// + /// This is derived from the `purchasedQuantity` property of the `transaction` object. + public var quantity: Int { + transaction.purchasedQuantity + } + + /// Completes the transaction by calling the `finish()` method on the `transaction` object. + /// + /// This should be called if `needsFinishTransaction` is `true`. + public func finish() async { + await transaction.finish() + } +} diff --git a/Sources/AdvancedCommerceMercato/Mercato+AdvancedCommerce.swift b/Sources/AdvancedCommerceMercato/Mercato+AdvancedCommerce.swift new file mode 100644 index 0000000..90043a6 --- /dev/null +++ b/Sources/AdvancedCommerceMercato/Mercato+AdvancedCommerce.swift @@ -0,0 +1,205 @@ +// +// File.swift +// Mercato +// +// Created by PT on 8/26/25. +// + +import Mercato +import StoreKit + +#if canImport(AppKit) +import AppKit + +public typealias PurchaseUIContext = NSWindow +#elseif os(iOS) +import UIKit + +public typealias PurchaseUIContext = UIViewController +#endif + +// MARK: - AdvancedCommerceMercato + +@available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) +public final class AdvancedCommerceMercato: Sendable { + private let acProductService: any AdvancedCommerceProductService + private let skProductService: any StoreKitProductService + + package convenience init() { + self.init( + acProductService: AdvancedCommerceCachingProductService(), + skProductService: CachingProductService() + ) + } + + public init( + acProductService: any AdvancedCommerceProductService, + skProductService: any StoreKitProductService + ) { + self.acProductService = acProductService + self.skProductService = skProductService + } + + public func retrieveProducts(productIds: Set) async throws(MercatoError) -> [AdvancedCommerceProduct] { + try await acProductService.retrieveProducts(productIds: productIds) + } + + public func allTransactions(for productId: AdvancedCommerceProduct.ID) async -> Transaction.Transactions? { + await acProductService.allTransactions(for: productId) + } + + public func currentEntitlements(for productId: AdvancedCommerceProduct.ID) async -> Transaction.Transactions? { + await acProductService.currentEntitlements(for: productId) + } + + public func latestTransaction(for productId: AdvancedCommerceProduct.ID) async -> VerificationResult? { + await acProductService.latestTransaction(for: productId) + } + + public static let shared = AdvancedCommerceMercato() +} + + +@available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) +@MainActor +extension AdvancedCommerceMercato { + public func purchase(productId: String, advancedCommerceData: Data) async throws -> Product.PurchaseResult { + guard let product = try await skProductService.retrieveProducts(productIds: [productId]).first else { + throw MercatoError.productNotFound(productId) + } + + let options = Product.PurchaseOption.advancedCommerceData(advancedCommerceData) + return try await product.purchase(options: [options]) + } + + #if !os(watchOS) + @available(iOS 18.4, macOS 15.4, tvOS 18.4, visionOS 2.4, *) + @available(watchOS, unavailable) + public func purchase( + productId: String, + compactJWS: String, + confirmIn view: PurchaseUIContext, + options: Set = [] + ) async throws -> AdvancedCommercePurchase { + guard let product = try await acProductService.retrieveProducts(productIds: [productId]).first else { + throw MercatoError.productNotFound(productId) + } + + do { + let result = try await product.purchase(compactJWS: compactJWS, confirmIn: view, options: options) + + return try await handlePurchaseResult( + result, + product: product, + finishAutomatically: false + ) + } catch { + throw MercatoError.wrapped(error: error) + } + } + #endif + + #if os(watchOS) + @available(watchOS 11.4, *) + @available(iOS, unavailable) + @available(macOS, unavailable) + @available(tvOS, unavailable) + @available(visionOS, unavailable) + public func purchase( + productId: String, + compactJWS: String, + options: Set = [] + ) async throws -> AdvancedCommercePurchase { + guard let product = try await productService.retrieveProducts(productIds: [productId]).first else { + throw MercatoError.productNotFound(productId) + } + + do { + let result = try await product.purchase(compactJWS: compactJWS, options: options) + + return try await handlePurchaseResult( + result, + product: product, + finishAutomatically: false + ) + } catch { + throw MercatoError.wrapped(error: error) + } + } + #endif + + private func handlePurchaseResult( + _ result: Product.PurchaseResult, + product: AdvancedCommerceProduct, + finishAutomatically: Bool + ) async throws(MercatoError) -> AdvancedCommercePurchase { + switch result { + case .success(let verification): + let transaction = try verification.payload + + if finishAutomatically { + await transaction.finish() + } + + return AdvancedCommercePurchase( + product: product, + result: verification, + needsFinishTransaction: !finishAutomatically + ) + case .userCancelled: + throw MercatoError.canceledByUser + case .pending: + throw MercatoError.purchaseIsPending + @unknown default: + throw MercatoError.unknown(error: nil) + } + } +} + +@available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) +extension AdvancedCommerceMercato { + public static func purchase(productId: String, advancedCommerceData: Data) async throws -> Product.PurchaseResult { + try await shared.purchase(productId: productId, advancedCommerceData: advancedCommerceData) + } + + #if !os(watchOS) + @available(iOS 18.4, macOS 15.4, tvOS 18.4, visionOS 2.4, *) + @available(watchOS, unavailable) + public static func purchase( + productId: String, + compactJWS: String, + confirmIn view: PurchaseUIContext, + options: Set = [] + ) async throws -> AdvancedCommercePurchase { + try await shared.purchase(productId: productId, compactJWS: compactJWS, confirmIn: view, options: options) + } + #endif + + #if os(watchOS) + @available(watchOS 11.4, *) + @available(iOS, unavailable) + @available(macOS, unavailable) + @available(tvOS, unavailable) + @available(visionOS, unavailable) + public static func purchase( + productId: String, + compactJWS: String, + options: Set = [] + ) async throws -> AdvancedCommercePurchase { + try await shared.purchase(productId: productId, compactJWS: compactJWS, options: options) + } + + #endif + + public static func allTransactions(for productId: AdvancedCommerceProduct.ID) async -> Transaction.Transactions? { + await shared.allTransactions(for: productId) + } + + public static func currentEntitlements(for productId: AdvancedCommerceProduct.ID) async -> Transaction.Transactions? { + await shared.currentEntitlements(for: productId) + } + + public static func latestTransaction(for productId: AdvancedCommerceProduct.ID) async -> VerificationResult? { + await shared.latestTransaction(for: productId) + } +} diff --git a/Sources/AdvancedCommerceMercato/Models/Descriptors.swift b/Sources/AdvancedCommerceMercato/Models/Descriptors.swift new file mode 100644 index 0000000..41b4153 --- /dev/null +++ b/Sources/AdvancedCommerceMercato/Models/Descriptors.swift @@ -0,0 +1,57 @@ +// MIT License +// +// Copyright (c) 2021-2025 Pavel T +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +// MARK: - Descriptors + +/// The description and display name of the subscription to migrate to that you manage. +public struct Descriptors: Decodable, Encodable { + /// A string you provide that describes a SKU. + /// + /// [Description](https://developer.apple.com/documentation/appstoreserverapi/description) + public var description: String + + /// A string with a product name that you can localize and is suitable for display to customers. + /// + /// [DisplayName](https://developer.apple.com/documentation/appstoreserverapi/displayname) + public var displayName: String + + public init(description: String, displayName: String) { + self.description = description + self.displayName = displayName + } + + public enum CodingKeys: String, CodingKey { + case description + case displayName + } +} + +// MARK: Validatable + +extension Descriptors: Validatable { + public func validate() throws { + try ValidationUtils.validateDescription(description) + try ValidationUtils.validateDisplayName(displayName) + } +} diff --git a/Sources/AdvancedCommerceMercato/Models/Effective.swift b/Sources/AdvancedCommerceMercato/Models/Effective.swift new file mode 100644 index 0000000..c9ee43c --- /dev/null +++ b/Sources/AdvancedCommerceMercato/Models/Effective.swift @@ -0,0 +1,29 @@ +// MIT License +// +// Copyright (c) 2021-2025 Pavel T +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +/// A string value that indicates when a requested change to an auto-renewable subscription goes into effect. +/// +/// [effective](https://developer.apple.com/documentation/advancedcommerceapi/effective) +public enum Effective: String, Codable, Hashable, Sendable { + case immediately = "IMMEDIATELY" + case nextBillCycle = "NEXT_BILL_CYCLE" +} diff --git a/Sources/AdvancedCommerceMercato/Models/Offer.swift b/Sources/AdvancedCommerceMercato/Models/Offer.swift new file mode 100644 index 0000000..a38e5ca --- /dev/null +++ b/Sources/AdvancedCommerceMercato/Models/Offer.swift @@ -0,0 +1,66 @@ +// MIT License +// +// Copyright (c) 2021-2025 Pavel T +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +// MARK: - Offer + +/// A discount offer for an auto-renewable subscription. +/// +/// [Offer](https://developer.apple.com/documentation/advancedcommerceapi/offer) +public struct Offer: Codable { + + /// The period of the offer. + public var period: OfferPeriod + + /// The number of periods the offer is active. + public var periodCount: Int32 + + /// The offer price, in milliunits. + /// + /// [Price](https://developer.apple.com/documentation/advancedcommerceapi/price) + public var price: Int64 + + /// The reason for the offer. + public var reason: OfferReason + + public init( + period: OfferPeriod, + periodCount: Int32, + price: Int64, + reason: OfferReason + ) { + self.period = period + self.periodCount = periodCount + self.price = price + self.reason = reason + } + +} + +// MARK: Validatable + +extension Offer: Validatable { + public func validate() throws { + try ValidationUtils.validatePrice(price) + } +} diff --git a/Sources/AdvancedCommerceMercato/Models/OfferPeriod.swift b/Sources/AdvancedCommerceMercato/Models/OfferPeriod.swift new file mode 100644 index 0000000..46faebd --- /dev/null +++ b/Sources/AdvancedCommerceMercato/Models/OfferPeriod.swift @@ -0,0 +1,36 @@ +// MIT License +// +// Copyright (c) 2021-2025 Pavel T +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +/// The period of the offer. +/// +/// [Offer](https://developer.apple.com/documentation/advancedcommerceapi/offer) +public enum OfferPeriod: String, Decodable, Encodable, Hashable, Sendable { + case p3d = "P3D" + case p1w = "P1W" + case p2w = "P2W" + case p1m = "P1M" + case p2m = "P2M" + case p3m = "P3M" + case p6m = "P6M" + case p9m = "P9M" + case p1y = "P1Y" +} diff --git a/Sources/AdvancedCommerceMercato/Models/OfferReason.swift b/Sources/AdvancedCommerceMercato/Models/OfferReason.swift new file mode 100644 index 0000000..8010fef --- /dev/null +++ b/Sources/AdvancedCommerceMercato/Models/OfferReason.swift @@ -0,0 +1,30 @@ +// MIT License +// +// Copyright (c) 2021-2025 Pavel T +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +/// The reason for the offer. +/// +/// [Offer](https://developer.apple.com/documentation/advancedcommerceapi/offer) +public enum OfferReason: String, Decodable, Encodable, Hashable, Sendable { + case acquisition = "ACQUISITION" + case winBack = "WIN_BACK" + case retention = "RETENTION" +} diff --git a/Sources/AdvancedCommerceMercato/Models/OneTimeChargeCreateRequest.swift b/Sources/AdvancedCommerceMercato/Models/OneTimeChargeCreateRequest.swift new file mode 100644 index 0000000..80e31ff --- /dev/null +++ b/Sources/AdvancedCommerceMercato/Models/OneTimeChargeCreateRequest.swift @@ -0,0 +1,89 @@ +// MIT License +// +// Copyright (c) 2021-2025 Pavel T +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +// MARK: - OneTimeChargeCreateRequest + +/// The request data your app provides when a customer purchases a one-time-charge product. +/// +/// [OneTimeChargeCreateRequest](https://developer.apple.com/documentation/advancedcommerceapi/onetimechargecreaterequest) +public struct OneTimeChargeCreateRequest: Codable { + + /// The operation type for this request. + public var operation: String = RequestOperation.oneTimeCharge.rawValue + + /// The version of this request. + public var version: String = RequestVersion.v1.rawValue + + /// The metadata to include in server requests. + /// + /// [requestInfo](https://developer.apple.com/documentation/advancedcommerceapi/requestinfo) + public var requestInfo: RequestInfo + + /// The currency of the price of the product. + /// + /// [currency](https://developer.apple.com/documentation/advancedcommerceapi/currency) + public var currency: String + + /// The details of the product for purchase. + /// + /// [OneTimeChargeItem](https://developer.apple.com/documentation/advancedcommerceapi/onetimechargeitem) + public var item: OneTimeChargeItem + + /// The storefront for the transaction. + /// + /// [storefront](https://developer.apple.com/documentation/advancedcommerceapi/onetimechargecreaterequest) + public var storefront: String? + + /// The tax code for this product. + /// + /// [taxCode](https://developer.apple.com/documentation/advancedcommerceapi/onetimechargecreaterequest) + public var taxCode: String + + /// Convenience initializer + public init( + currency: String, + item: OneTimeChargeItem, + requestInfo: RequestInfo, + taxCode: String, + storefront: String? = nil + ) { + self.requestInfo = requestInfo + self.currency = currency + self.item = item + self.taxCode = taxCode + self.storefront = storefront + } +} + +// MARK: Validatable + +extension OneTimeChargeCreateRequest: Validatable { + public func validate() throws { + try requestInfo.validate() + + try ValidationUtils.validateCurrency(currency) + try ValidationUtils.validateTaxCode(taxCode) + if let storefront { try ValidationUtils.validateStorefront(storefront) } + } +} diff --git a/Sources/AdvancedCommerceMercato/Models/OneTimeChargeItem.swift b/Sources/AdvancedCommerceMercato/Models/OneTimeChargeItem.swift new file mode 100644 index 0000000..0b2e3d6 --- /dev/null +++ b/Sources/AdvancedCommerceMercato/Models/OneTimeChargeItem.swift @@ -0,0 +1,74 @@ +// MIT License +// +// Copyright (c) 2021-2025 Pavel T +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// MARK: - OneTimeChargeItem + +/// The details of a one-time charge product, including its display name, price, SKU, and metadata. +/// +/// [OneTimeChargeItem](https://developer.apple.com/documentation/advancedcommerceapi/onetimechargeitem) +public struct OneTimeChargeItem: Decodable, Encodable { + + /// The product identifier. + /// + /// [SKU](https://developer.apple.com/documentation/advancedcommerceapi/sku) + public var sku: String + + /// A description of the product that doesn’t display to customers. + /// + /// [description](https://developer.apple.com/documentation/advancedcommerceapi/description) + public var description: String + + /// The product name, suitable for display to customers. + /// + /// [displayName](https://developer.apple.com/documentation/advancedcommerceapi/displayName) + public var displayName: String + + /// The price, in milliunits of the currency, of the one-time charge product. + /// + /// [Price](https://developer.apple.com/documentation/advancedcommerceapi/price) + public var price: Int64 + + public init(sku: String, description: String, displayName: String, price: Int64) { + self.sku = sku + self.description = description + self.displayName = displayName + self.price = price + } + + public enum CodingKeys: String, CodingKey { + case sku = "SKU" + case description + case displayName + case price + } +} + +// MARK: Validatable + +extension OneTimeChargeItem: Validatable { + public func validate() throws { + try ValidationUtils.validateSku(sku) + try ValidationUtils.validateDescription(description) + try ValidationUtils.validateDisplayName(displayName) + try ValidationUtils.validatePrice(price) + } +} diff --git a/Sources/AdvancedCommerceMercato/Models/Period.swift b/Sources/AdvancedCommerceMercato/Models/Period.swift new file mode 100644 index 0000000..9828fa2 --- /dev/null +++ b/Sources/AdvancedCommerceMercato/Models/Period.swift @@ -0,0 +1,33 @@ +// MIT License +// +// Copyright (c) 2021-2025 Pavel T +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +/// The duration of a single cycle of an auto-renewable subscription. +/// +/// [period](https://developer.apple.com/documentation/advancedcommerceapi/period) +public enum Period: String, Decodable, Encodable, Hashable, Sendable { + case p1w = "P1W" + case p1m = "P1M" + case p2m = "P2M" + case p3m = "P3M" + case p6m = "P6M" + case p1y = "P1Y" +} diff --git a/Sources/AdvancedCommerceMercato/Models/Reason.swift b/Sources/AdvancedCommerceMercato/Models/Reason.swift new file mode 100644 index 0000000..d020d94 --- /dev/null +++ b/Sources/AdvancedCommerceMercato/Models/Reason.swift @@ -0,0 +1,28 @@ +// MIT License +// +// Copyright (c) 2021-2025 Pavel T +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +/// +public enum Reason: String, Codable, Hashable, Sendable { + case upgrade = "UPGRADE" + case downgrade = "DOWNGRADE" + case applyOffer = "APPLY_OFFER" +} diff --git a/Sources/AdvancedCommerceMercato/Models/RequestInfo.swift b/Sources/AdvancedCommerceMercato/Models/RequestInfo.swift new file mode 100644 index 0000000..bca203d --- /dev/null +++ b/Sources/AdvancedCommerceMercato/Models/RequestInfo.swift @@ -0,0 +1,66 @@ +// MIT License +// +// Copyright (c) 2021-2025 Pavel T +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +// MARK: - RequestInfo + +/// The metadata to include in server requests. +/// +/// [RequestInfo](https://developer.apple.com/documentation/advancedcommerceapi/requestinfo) +public struct RequestInfo: Decodable, Encodable, Hashable, Sendable { + /// The app account token for the request. + /// + /// [App Account Token](https://developer.apple.com/documentation/advancedcommerceapi/appaccounttoken) + public var appAccountToken: UUID? + + /// The consistency token for the request. + /// + /// [Consistency Token](https://developer.apple.com/documentation/advancedcommerceapi/consistencytoken) + public var consistencyToken: String? + + /// The request reference identifier. + /// + /// [Request Reference ID](https://developer.apple.com/documentation/advancedcommerceapi/requestreferenceid) + public var requestReferenceId: UUID + + public init(appAccountToken: UUID? = nil, consistencyToken: String? = nil, requestReferenceId: UUID) { + self.appAccountToken = appAccountToken + self.consistencyToken = consistencyToken + self.requestReferenceId = requestReferenceId + } + + public enum CodingKeys: CodingKey { + case appAccountToken + case consistencyToken + case requestReferenceId + } +} + +// MARK: Validatable + +extension RequestInfo: Validatable { + public func validate() throws { + if let appAccountToken { try ValidationUtils.validUUID(appAccountToken) } + try ValidationUtils.validUUID(requestReferenceId) + } +} diff --git a/Sources/AdvancedCommerceMercato/Models/RequestOperation.swift b/Sources/AdvancedCommerceMercato/Models/RequestOperation.swift new file mode 100644 index 0000000..6fbd289 --- /dev/null +++ b/Sources/AdvancedCommerceMercato/Models/RequestOperation.swift @@ -0,0 +1,30 @@ +// MIT License +// +// Copyright (c) 2021-2025 Pavel T +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +public enum RequestOperation: String, Codable { + case createSubscription = "CREATE_SUBSCRIPTION" + case oneTimeCharge = "CREATE_ONE_TIME_CHARGE" + case modifySubscription = "MODIFY_SUBSCRIPTION" + case reactivateSubscription = "REACTIVATE_SUBSCRIPTION" +} diff --git a/Sources/AdvancedCommerceMercato/Models/RequestVersion.swift b/Sources/AdvancedCommerceMercato/Models/RequestVersion.swift new file mode 100644 index 0000000..5c69c4c --- /dev/null +++ b/Sources/AdvancedCommerceMercato/Models/RequestVersion.swift @@ -0,0 +1,27 @@ +// MIT License +// +// Copyright (c) 2021-2025 Pavel T +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +public enum RequestVersion: String, Codable { + case v1 = "1" +} diff --git a/Sources/AdvancedCommerceMercato/Models/SubscriptionCreateItem.swift b/Sources/AdvancedCommerceMercato/Models/SubscriptionCreateItem.swift new file mode 100644 index 0000000..6c1c06d --- /dev/null +++ b/Sources/AdvancedCommerceMercato/Models/SubscriptionCreateItem.swift @@ -0,0 +1,90 @@ +// MIT License +// +// Copyright (c) 2021-2025 Pavel T +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +// MARK: - SubscriptionCreateItem + +/// The data that describes a subscription item. +/// +/// [Advanced Commerce API Documentation](https://developer.apple.com/documentation/advancedcommerceapi/SubscriptionCreateItem) +public struct SubscriptionCreateItem: Decodable, Encodable { + + /// The SKU identifier for the item. + /// + /// [SKU](https://developer.apple.com/documentation/advancedcommerceapi/sku) + public var sku: String + + /// The description of the item. + /// + /// [Description](https://developer.apple.com/documentation/advancedcommerceapi/description) + public var description: String + + /// The display name of the item. + /// + /// [Display Name](https://developer.apple.com/documentation/advancedcommerceapi/displayname) + public var displayName: String + + /// The number of periods for billing. + /// + /// [Offer](https://developer.apple.com/documentation/advancedcommerceapi/offer) + public var offer: Offer? + + /// The price in milliunits. + /// + /// [Price](https://developer.apple.com/documentation/advancedcommerceapi/price) + public var price: Int64 + + + public init( + sku: String, + description: String, + displayName: String, + offer: Offer?, + price: Int64 + ) { + self.sku = sku + self.description = description + self.displayName = displayName + self.offer = offer + self.price = price + } + + public enum CodingKeys: String, CodingKey { + case sku = "SKU" + case description + case displayName + case offer + case price + } +} + +// MARK: Validatable + +extension SubscriptionCreateItem: Validatable { + public func validate() throws { + try ValidationUtils.validateSku(sku) + try ValidationUtils.validateDescription(description) + try ValidationUtils.validateDisplayName(displayName) + try ValidationUtils.validatePrice(price) + } +} diff --git a/Sources/AdvancedCommerceMercato/Models/SubscriptionCreateRequest.swift b/Sources/AdvancedCommerceMercato/Models/SubscriptionCreateRequest.swift new file mode 100644 index 0000000..f1c4802 --- /dev/null +++ b/Sources/AdvancedCommerceMercato/Models/SubscriptionCreateRequest.swift @@ -0,0 +1,127 @@ +// MIT License +// +// Copyright (c) 2021-2025 Pavel T +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +// MARK: - SubscriptionCreateRequest + +/// The metadata your app provides when a customer purchases an auto-renewable subscription. +/// +/// [SubscriptionCreateRequest](https://developer.apple.com/documentation/advancedcommerceapi/subscriptioncreaterequest) +public struct SubscriptionCreateRequest: Decodable, Encodable { + + /// The operation type for this request. + public var operation: String = RequestOperation.createSubscription.rawValue + + /// The version of this request. + public var version: String = RequestVersion.v1.rawValue + + /// The currency of the price of the product. + /// + /// [currency](https://developer.apple.com/documentation/advancedcommerceapi/currency) + public var currency: String + + /// The display name and description of a subscription product. + /// + /// [Descriptors](https://developer.apple.com/documentation/advancedcommerceapi/descriptors) + public var descriptors: Descriptors + + /// The details of the subscription product for purchase. + /// + /// [SubscriptionCreateItem](https://developer.apple.com/documentation/advancedcommerceapi/subscriptioncreateitem) + public var items: [SubscriptionCreateItem] + + /// The duration of a single cycle of an auto-renewable subscription. + /// + /// [period](https://developer.apple.com/documentation/advancedcommerceapi/period) + public var period: Period + + /// The identifier of a previous transaction for the subscription. + /// + /// [transactionId](https://developer.apple.com/documentation/advancedcommerceapi/transactionid) + public var previousTransactionId: String? + + /// The metadata to include in server requests. + /// + /// [requestInfo](https://developer.apple.com/documentation/advancedcommerceapi/requestinfo) + public var requestInfo: RequestInfo + + /// The storefront for the transaction. + /// + /// [storefront](https://developer.apple.com/documentation/advancedcommerceapi/onetimechargecreaterequest) + public var storefront: String? + + /// The tax code for this product. + /// + /// [taxCode](https://developer.apple.com/documentation/advancedcommerceapi/onetimechargecreaterequest) + public var taxCode: String + + public init( + currency: String, + descriptors: Descriptors, + items: [SubscriptionCreateItem], + period: Period, + previousTransactionId: String? = nil, + requestInfo: RequestInfo, + storefront: String? = nil, + taxCode: String + ) { + self.currency = currency + self.descriptors = descriptors + self.items = items + self.period = period + self.previousTransactionId = previousTransactionId + self.requestInfo = requestInfo + self.storefront = nil + self.taxCode = taxCode + } + + + public enum CodingKeys: String, CodingKey, CaseIterable { + case operation + case version + case currency + case descriptors + case items + case period + case previousTransactionId + case requestInfo + case storefront + case taxCode + } +} + +// MARK: Validatable + +extension SubscriptionCreateRequest: Validatable { + public func validate() throws { + try descriptors.validate() + try requestInfo.validate() + + try items.forEach { try $0.validate() } + try ValidationUtils.validateCurrency(currency) + try ValidationUtils.validateTaxCode(taxCode) + + if let storefront { try ValidationUtils.validateStorefront(storefront) } + if let previousTransactionId { try ValidationUtils.validateTransactionId(previousTransactionId) } + } +} diff --git a/Sources/AdvancedCommerceMercato/Models/SubscriptionModifyAddItem.swift b/Sources/AdvancedCommerceMercato/Models/SubscriptionModifyAddItem.swift new file mode 100644 index 0000000..36ddfe5 --- /dev/null +++ b/Sources/AdvancedCommerceMercato/Models/SubscriptionModifyAddItem.swift @@ -0,0 +1,99 @@ +// MIT License +// +// Copyright (c) 2021-2025 Pavel T +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// MARK: - SubscriptionModifyAddItem + +/// An item for adding to Advanced Commerce subscription modifications. +/// +/// [SubscriptionModifyAddItem](https://developer.apple.com/documentation/advancedcommerceapi/SubscriptionModifyAddItem) +public struct SubscriptionModifyAddItem: Decodable, Encodable { + + public init( + sku: String, + description: String, + displayName: String, + offer: Offer? = nil, + price: Int64, + proratedPrice: Int64? + ) { + self.sku = sku + self.description = description + self.displayName = displayName + self.offer = offer + self.price = price + self.proratedPrice = proratedPrice + } + + /// The SKU identifier for the item. + /// + /// [SKU](https://developer.apple.com/documentation/advancedcommerceapi/sku) + public var sku: String + + /// The description of the item. + /// + /// [Description](https://developer.apple.com/documentation/advancedcommerceapi/description) + public var description: String + + /// The display name of the item. + /// + /// [Display Name](https://developer.apple.com/documentation/advancedcommerceapi/displayname) + public var displayName: String + + /// Offer. + /// + /// [offer](https://developer.apple.com/documentation/advancedcommerceapi/offer) + public var offer: Offer? + + /// The price, in milliunits of the currency, of the one-time charge product. + /// + /// [Price](https://developer.apple.com/documentation/advancedcommerceapi/price) + public var price: Int64 + + /// The price, in milliunits of the currency, of the one-time charge product. + /// + /// [proratedPrice](https://developer.apple.com/documentation/advancedcommerceapi/proratedPrice) + public var proratedPrice: Int64? + + public enum CodingKeys: String, CodingKey { + case sku = "SKU" + case description + case displayName + case offer + case price + case proratedPrice + } +} + +// MARK: Validatable + +extension SubscriptionModifyAddItem: Validatable { + public func validate() throws { + try offer?.validate() + + try ValidationUtils.validateSku(sku) + try ValidationUtils.validateDescription(description) + try ValidationUtils.validateDisplayName(displayName) + try ValidationUtils.validatePrice(price) + + if let proratedPrice { try ValidationUtils.validatePrice(proratedPrice) } + } +} diff --git a/Sources/AdvancedCommerceMercato/Models/SubscriptionModifyChangeItem.swift b/Sources/AdvancedCommerceMercato/Models/SubscriptionModifyChangeItem.swift new file mode 100644 index 0000000..bf874c7 --- /dev/null +++ b/Sources/AdvancedCommerceMercato/Models/SubscriptionModifyChangeItem.swift @@ -0,0 +1,124 @@ +// MIT License +// +// Copyright (c) 2021-2025 Pavel T +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// MARK: - SubscriptionModifyChangeItem + +/// An item for changing Advanced Commerce subscription modifications. +/// +/// [SubscriptionModifyChangeItem](https://developer.apple.com/documentation/advancedcommerceapi/SubscriptionModifyChangeItem) +public struct SubscriptionModifyChangeItem: Codable { + + /// The SKU identifier for the item. + /// + /// [SKU](https://developer.apple.com/documentation/advancedcommerceapi/sku) + public var sku: String + + /// The SKU identifier for the item. + /// + /// [SKU](https://developer.apple.com/documentation/advancedcommerceapi/sku) + public var currentSku: String + + /// The description of the item. + /// + /// [Description](https://developer.apple.com/documentation/advancedcommerceapi/description) + public var description: String + + /// The display name of the item. + /// + /// [Display Name](https://developer.apple.com/documentation/advancedcommerceapi/displayname) + public var displayName: String + + /// When the modification takes effect. + /// + /// [Effective](https://developer.apple.com/documentation/advancedcommerceapi/effective) + public var effective: Effective + + /// Offer. + /// + /// [offer](https://developer.apple.com/documentation/advancedcommerceapi/offer) + public var offer: Offer? + + /// The price, in milliunits of the currency, of the one-time charge product. + /// + /// [Price](https://developer.apple.com/documentation/advancedcommerceapi/price) + public var price: Int64 + + /// The price, in milliunits of the currency, of the one-time charge product. + /// + /// [proratedPrice](https://developer.apple.com/documentation/advancedcommerceapi/proratedPrice) + public var proratedPrice: Int64? + + /// Reason + /// + /// [Reason](Reason) + public var reason: Reason + + init( + sku: String, + currentSku: String, + description: String, + displayName: String, + effective: Effective, + offer: Offer? = nil, + price: Int64, + proratedPrice: Int64? = nil, + reason: Reason + ) { + self.sku = sku + self.currentSku = currentSku + self.description = description + self.displayName = displayName + self.effective = effective + self.offer = offer + self.price = price + self.proratedPrice = proratedPrice + self.reason = reason + } + + public enum CodingKeys: String, CodingKey { + case sku = "SKU" + case currentSku = "currentSKU" + case description + case displayName + case effective + case offer + case price + case proratedPrice + case reason + } +} + +// MARK: Validatable + +extension SubscriptionModifyChangeItem: Validatable { + public func validate() throws { + try offer?.validate() + + try ValidationUtils.validateSku(sku) + try ValidationUtils.validateSku(currentSku) + try ValidationUtils.validateDescription(description) + try ValidationUtils.validateDisplayName(displayName) + try ValidationUtils.validatePrice(price) + + if let proratedPrice { try ValidationUtils.validatePrice(proratedPrice) } + } +} diff --git a/Sources/AdvancedCommerceMercato/Models/SubscriptionModifyDescriptors.swift b/Sources/AdvancedCommerceMercato/Models/SubscriptionModifyDescriptors.swift new file mode 100644 index 0000000..681b30f --- /dev/null +++ b/Sources/AdvancedCommerceMercato/Models/SubscriptionModifyDescriptors.swift @@ -0,0 +1,61 @@ +// MIT License +// +// Copyright (c) 2021-2025 Pavel T +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + + +import Foundation + +// MARK: - SubscriptionModifyDescriptors + +/// Descriptors for Advanced Commerce subscription modifications. +/// +/// [SubscriptionModifyDescriptors](https://developer.apple.com/documentation/advancedcommerceapi/SubscriptionModifyDescriptors) +public struct SubscriptionModifyDescriptors: Codable, Hashable, Sendable { + /// The description of the item. + /// + /// [Description](https://developer.apple.com/documentation/advancedcommerceapi/description) + public var description: String? + + /// The display name of the item. + /// + /// [Display Name](https://developer.apple.com/documentation/advancedcommerceapi/displayname) + public var displayName: String? + + /// When the modification takes effect. + /// + /// [Effective](https://developer.apple.com/documentation/advancedcommerceapi/effective) + public var effective: Effective + + init(description: String? = nil, displayName: String? = nil, effective: Effective) { + self.description = description + self.displayName = displayName + self.effective = effective + } +} + +// MARK: Validatable + +extension SubscriptionModifyDescriptors: Validatable { + public func validate() throws { + if let description { try ValidationUtils.validateDescription(description) } + if let displayName { try ValidationUtils.validateDisplayName(displayName) } + } +} diff --git a/Sources/AdvancedCommerceMercato/Models/SubscriptionModifyInAppRequest.swift b/Sources/AdvancedCommerceMercato/Models/SubscriptionModifyInAppRequest.swift new file mode 100644 index 0000000..f3c2c47 --- /dev/null +++ b/Sources/AdvancedCommerceMercato/Models/SubscriptionModifyInAppRequest.swift @@ -0,0 +1,226 @@ +// MIT License +// +// Copyright (c) 2021-2025 Pavel T +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +// MARK: - SubscriptionModifyInAppRequest + +/// The request data your app provides to make changes to an auto-renewable subscription. +/// +/// [SubscriptionModifyInAppRequest](https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmodifyinapprequest) +public struct SubscriptionModifyInAppRequest: Decodable, Encodable { + + /// The operation type for this request. + public var operation: String = RequestOperation.modifySubscription.rawValue + + /// The version of this request. + public var version: String = RequestVersion.v1.rawValue + + /// The metadata to include in server requests. + /// + /// [requestInfo](https://developer.apple.com/documentation/advancedcommerceapi/requestinfo) + public var requestInfo: RequestInfo + + /// The data your app provides to add items when it makes changes to an auto-renewable subscription. + /// + /// [SubscriptionModifyAddItem](https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmodifyadditem) + public var addItems: [SubscriptionModifyAddItem]? + + /// The data your app provides to change an item of an auto-renewable subscription. + /// + /// [SubscriptionModifyChangeItem](https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmodifychangeitem) + public var changeItems: [SubscriptionModifyChangeItem]? + + /// The data your app provides to remove items from an auto-renewable subscription. + /// + /// [SubscriptionModifyRemoveItem](https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmodifyremoveitem) + public var removeItems: [SubscriptionModifyRemoveItem]? + + /// The currency of the price of the product. + /// + /// [currency](https://developer.apple.com/documentation/advancedcommerceapi/currency) + public var currency: String? + + /// The data your app provides to change the description and display name of an auto-renewable subscription. + /// + /// [SubscriptionModifyDescriptors](https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmodifydescriptors) + public var descriptors: SubscriptionModifyDescriptors? + + /// The data your app provides to change the period of an auto-renewable subscription. + /// + /// [SubscriptionModifyPeriodChange](https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmodifyperiodchange) + public var periodChange: SubscriptionModifyPeriodChange? + + /// A Boolean value that determines whether to keep the existing billing cycle with the change you request. + /// + /// [retainBillingCycle](https://developer.apple.com/documentation/advancedcommerceapi/retainbillingcycle) + public var retainBillingCycle: Bool + + /// The storefront for the transaction. + /// + /// [storefront](https://developer.apple.com/documentation/advancedcommerceapi/onetimechargecreaterequest) + public var storefront: String? + + /// The tax code for this product. + /// + /// [taxCode](https://developer.apple.com/documentation/advancedcommerceapi/taxcode) + public var taxCode: String? + + /// A unique identifier that the App Store generates for a transaction. + /// + /// [transactionId](https://developer.apple.com/documentation/advancedcommerceapi/transactionid) + public var transactionId: String + + init( + requestInfo: RequestInfo, + addItems: [SubscriptionModifyAddItem]? = nil, + changeItems: [SubscriptionModifyChangeItem]? = nil, + removeItems: [SubscriptionModifyRemoveItem]? = nil, + currency: String? = nil, + descriptors: SubscriptionModifyDescriptors? = nil, + periodChange: SubscriptionModifyPeriodChange? = nil, + retainBillingCycle: Bool, + storefront: String? = nil, + taxCode: String? = nil, + transactionId: String + ) { + self.requestInfo = requestInfo + self.addItems = addItems + self.changeItems = changeItems + self.removeItems = removeItems + self.currency = currency + self.descriptors = descriptors + self.periodChange = periodChange + self.retainBillingCycle = retainBillingCycle + self.storefront = storefront + self.taxCode = taxCode + self.transactionId = transactionId + } + + public enum CodingKeys: String, CodingKey { + case operation + case version + case requestInfo + case addItems + case changeItems + case currency + case descriptors + case periodChange + case removeItems + case retainBillingCycle + case storefront + case taxCode + case transactionId + } +} + +// MARK: - SubscriptionModifyInAppRequest Builder +extension SubscriptionModifyInAppRequest { + public func addItems(_ addItems: [SubscriptionModifyAddItem]?) -> SubscriptionModifyInAppRequest { + var updated = self + updated.addItems = addItems + return updated + } + + public func addAddItem(_ addItem: SubscriptionModifyAddItem) -> SubscriptionModifyInAppRequest { + var updated = self + if updated.addItems == nil { + updated.addItems = [] + } + updated.addItems?.append(addItem) + return updated + } + + public func changeItems(_ changeItems: [SubscriptionModifyChangeItem]?) -> SubscriptionModifyInAppRequest { + var updated = self + updated.changeItems = changeItems + return updated + } + + public func addChangeItem(_ changeItem: SubscriptionModifyChangeItem) -> SubscriptionModifyInAppRequest { + var updated = self + if updated.changeItems == nil { + updated.changeItems = [] + } + updated.changeItems?.append(changeItem) + return updated + } + + public func currency(_ currency: String?) -> SubscriptionModifyInAppRequest { + var updated = self + updated.currency = currency + return updated + } + + public func descriptors(_ descriptors: SubscriptionModifyDescriptors?) -> SubscriptionModifyInAppRequest { + var updated = self + updated.descriptors = descriptors + return updated + } + + public func periodChange(_ periodChange: SubscriptionModifyPeriodChange?) -> SubscriptionModifyInAppRequest { + var updated = self + updated.periodChange = periodChange + return updated + } + + public func removeItems(_ removeItems: [SubscriptionModifyRemoveItem]?) -> SubscriptionModifyInAppRequest { + var updated = self + updated.removeItems = removeItems + return updated + } + + public func addRemoveItem(_ removeItem: SubscriptionModifyRemoveItem) -> SubscriptionModifyInAppRequest { + var updated = self + if updated.removeItems == nil { + updated.removeItems = [] + } + updated.removeItems?.append(removeItem) + return updated + } + + public func storefront(_ storefront: String?) -> SubscriptionModifyInAppRequest { + var updated = self + updated.storefront = storefront + return updated + } + + public func taxCode(_ taxCode: String?) -> SubscriptionModifyInAppRequest { + var updated = self + updated.taxCode = taxCode + return updated + } +} + +extension SubscriptionModifyInAppRequest: Validatable { + public func validate() throws { + try requestInfo.validate() + + if let addItems { try addItems.forEach { try $0.validate() } } + if let changeItems { try changeItems.forEach { try $0.validate() } } + if let removeItems { try removeItems.forEach { try $0.validate() } } + if let descriptors { try descriptors.validate() } + if let currency { try ValidationUtils.validateCurrency(currency) } + if let taxCode { try ValidationUtils.validateTaxCode(taxCode) } + if let storefront { try ValidationUtils.validateStorefront(storefront) } + } +} diff --git a/Sources/AdvancedCommerceMercato/Models/SubscriptionModifyPeriodChange.swift b/Sources/AdvancedCommerceMercato/Models/SubscriptionModifyPeriodChange.swift new file mode 100644 index 0000000..9138df4 --- /dev/null +++ b/Sources/AdvancedCommerceMercato/Models/SubscriptionModifyPeriodChange.swift @@ -0,0 +1,45 @@ +// MIT License +// +// Copyright (c) 2021-2025 Pavel T +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + + +import Foundation + +/// A period change for Advanced Commerce subscription modifications. +/// +/// [SubscriptionModifyPeriodChange](https://developer.apple.com/documentation/advancedcommerceapi/SubscriptionModifyPeriodChange) +public struct SubscriptionModifyPeriodChange: Decodable, Encodable, Hashable, Sendable { + + /// When the modification takes effect. + /// + /// [Effective](https://developer.apple.com/documentation/advancedcommerceapi/effective) + public var effective: Effective + + /// Period. + /// + /// [period](https://developer.apple.com/documentation/advancedcommerceapi/period) + public var period: Period + + init(effective: Effective, period: Period) { + self.effective = effective + self.period = period + } +} diff --git a/Sources/AdvancedCommerceMercato/Models/SubscriptionModifyRemoveItem.swift b/Sources/AdvancedCommerceMercato/Models/SubscriptionModifyRemoveItem.swift new file mode 100644 index 0000000..7841608 --- /dev/null +++ b/Sources/AdvancedCommerceMercato/Models/SubscriptionModifyRemoveItem.swift @@ -0,0 +1,50 @@ +// MIT License +// +// Copyright (c) 2021-2025 Pavel T +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// MARK: - SubscriptionModifyRemoveItem + +/// An item for removing from Advanced Commerce subscription modifications. +/// +/// [SubscriptionModifyRemoveItem](https://developer.apple.com/documentation/advancedcommerceapi/SubscriptionModifyRemoveItem) +public struct SubscriptionModifyRemoveItem: Decodable, Encodable { + + /// The SKU identifier for the item. + /// + /// [SKU](https://developer.apple.com/documentation/advancedcommerceapi/sku) + public var sku: String + + init(sku: String) { + self.sku = sku + } + + public enum CodingKeys: String, CodingKey { + case sku = "SKU" + } +} + +// MARK: Validatable + +extension SubscriptionModifyRemoveItem: Validatable { + public func validate() throws { + try ValidationUtils.validateSku(sku) + } +} diff --git a/Sources/AdvancedCommerceMercato/Models/SubscriptionReactivateInAppRequest.swift b/Sources/AdvancedCommerceMercato/Models/SubscriptionReactivateInAppRequest.swift new file mode 100644 index 0000000..a2e023b --- /dev/null +++ b/Sources/AdvancedCommerceMercato/Models/SubscriptionReactivateInAppRequest.swift @@ -0,0 +1,115 @@ +// MIT License +// +// Copyright (c) 2021-2025 Pavel T +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + + +import Foundation + +// MARK: - SubscriptionReactivateInAppRequest + +/// The request data your app provides to reactivate an auto-renewable subscription. +/// +/// [SubscriptionReactivateInAppRequest](https://developer.apple.com/documentation/advancedcommerceapi/subscriptionreactivateinapprequest) +public struct SubscriptionReactivateInAppRequest: Decodable, Encodable { + + /// The operation type for this request. + public var operation: String = RequestOperation.reactivateSubscription.rawValue + + /// The version of this request. + public var version: String = RequestVersion.v1.rawValue + + /// The metadata to include in server requests. + /// + /// [requestInfo](https://developer.apple.com/documentation/advancedcommerceapi/requestinfo) + public var requestInfo: RequestInfo + + /// The list of items to reactivate in the subscription. + /// + /// [SubscriptionReactivateItem](https://developer.apple.com/documentation/advancedcommerceapi/subscriptionreactivateitem) + public var items: [SubscriptionReactivateItem]? + + /// The transaction identifier, which may be an original transaction identifier, of any transaction belonging to the customer. Provide this field to limit the notification history request to this one customer. + /// Include either the transactionId or the notificationType in your query, but not both. + /// + /// [transactionId](https://developer.apple.com/documentation/appstoreserverapi/transactionid) + public var transactionId: String + + /// The storefront for the transaction. + /// + /// [storefront](https://developer.apple.com/documentation/advancedcommerceapi/onetimechargecreaterequest) + public var storefront: String? + + public init( + requestInfo: RequestInfo, + items: [SubscriptionReactivateItem]? = nil, + transactionId: String, + storefront: String? = nil + ) { + self.requestInfo = requestInfo + self.items = items + self.transactionId = transactionId + self.storefront = storefront + } + + public enum CodingKeys: String, CodingKey, CaseIterable { + case operation + case version + case requestInfo + case transactionId + case items + case storefront + } +} + +// MARK: SubscriptionReactivateInAppRequest Builder +extension SubscriptionReactivateInAppRequest { + public func items(_ items: [SubscriptionReactivateItem]) -> SubscriptionReactivateInAppRequest { + var updated = self + updated.items = items + return updated + } + + public func addItem(_ item: SubscriptionReactivateItem) -> SubscriptionReactivateInAppRequest { + var updated = self + if updated.items == nil { + updated.items = [] + } + updated.items?.append(item) + return updated + } + + public func storefront(_ storefront: String) -> SubscriptionReactivateInAppRequest { + var updated = self + updated.storefront = storefront + return updated + } +} + +extension SubscriptionReactivateInAppRequest: Validatable { + public func validate() throws { + try requestInfo.validate() + + try ValidationUtils.validateTransactionId(transactionId) + + if let items { try items.forEach { try $0.validate() } } + if let storefront { try ValidationUtils.validateStorefront(storefront) } + } +} diff --git a/Sources/AdvancedCommerceMercato/Models/SubscriptionReactivateItem.swift b/Sources/AdvancedCommerceMercato/Models/SubscriptionReactivateItem.swift new file mode 100644 index 0000000..fe57c3f --- /dev/null +++ b/Sources/AdvancedCommerceMercato/Models/SubscriptionReactivateItem.swift @@ -0,0 +1,53 @@ +// MIT License +// +// Copyright (c) 2021-2025 Pavel T +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + + +import Foundation + +// MARK: - SubscriptionReactivateItem + +/// An item for reactivating Advanced Commerce subscriptions. +/// +/// [SubscriptionReactivateItem](https://developer.apple.com/documentation/advancedcommerceapi/SubscriptionReactivateItem) +public struct SubscriptionReactivateItem: Decodable, Encodable { + + /// The SKU identifier for the item. + /// + /// [SKU](https://developer.apple.com/documentation/advancedcommerceapi/sku) + public var sku: String + + init(sku: String) { + self.sku = sku + } + + public enum CodingKeys: String, CodingKey { + case sku = "SKU" + } +} + +// MARK: Validatable + +extension SubscriptionReactivateItem: Validatable { + public func validate() throws { + try ValidationUtils.validateSku(sku) + } +} diff --git a/Sources/AdvancedCommerceMercato/Models/ValidationUtils.swift b/Sources/AdvancedCommerceMercato/Models/ValidationUtils.swift new file mode 100644 index 0000000..8f5f455 --- /dev/null +++ b/Sources/AdvancedCommerceMercato/Models/ValidationUtils.swift @@ -0,0 +1,158 @@ +// MIT License +// +// Copyright (c) 2021-2025 Pavel T +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +// MARK: - Validatable + +public protocol Validatable { + func validate() throws +} + +// MARK: - ValidationError + +public enum ValidationError: Error { + case invalidLength(String) + case invalidValue(String) +} + +// MARK: LocalizedError + +extension ValidationError: LocalizedError { + public var errorDescription: String? { + switch self { + case .invalidLength(let msg), + .invalidValue(let msg): + msg + } + } +} + +// MARK: - ValidationUtils + +public enum ValidationUtils { + enum Constants { + static let kCurrencyCodeLength = 3 + static let kMaximumStorefrontLength = 10 + static let kMaximumRequestReferenceIdLength = 36 + static let kMaximumDescriptionLength = 45 + static let kMaximumDisplayNameLength = 30 + static let kMaximumSkuLength = 128 + static let kISOCurrencyRegex = "^[A-Z]{3}$" + } + + /// Validates currency code according to ISO 4217 standard. + /// - Parameter currency: The currency code to validate + /// - Throws: ValidationError if validation fails + public static func validateCurrency(_ currency: String) throws { + guard currency.count == Constants.kCurrencyCodeLength else { + throw ValidationError.invalidLength("Currency must be a 3-letter ISO 4217 code") + } + guard currency.range(of: Constants.kISOCurrencyRegex, options: .regularExpression) != nil else { + throw ValidationError.invalidValue("Currency must contain only uppercase letters") + } + } + + /// Validates tax code is not empty. + /// - Parameter taxCode: The tax code to validate + /// - Throws: ValidationError if validation fails + public static func validateTaxCode(_ taxCode: String) throws { + guard !taxCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ValidationError.invalidValue("Tax code cannot be empty") + } + } + + /// Validates transactionId is not empty. + /// - Parameter transactionId: The transaction ID to validate + /// - Throws: ValidationError if validation fails + public static func validateTransactionId(_ transactionId: String) throws { + guard !transactionId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ValidationError.invalidValue("Transaction ID cannot be empty") + } + } + + /// Validates target product ID is not empty. + /// - Parameter targetProductId: The target product ID to validate + /// - Throws: ValidationError if validation fails + public static func validateTargetProductId(_ targetProductId: String) throws { + guard !targetProductId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ValidationError.invalidValue("Target Product ID cannot be empty") + } + } + + /// Validates UUID is not null and its string representation doesn't exceed maximum length. + /// - Parameter uuid: The UUID to validate + /// - Throws: ValidationError if validation fails + public static func validUUID(_ uuid: UUID) throws { + let uuidString = uuid.uuidString + guard uuidString.count <= Constants.kMaximumRequestReferenceIdLength else { + throw ValidationError.invalidLength("UUID string representation cannot exceed \(Constants.kMaximumRequestReferenceIdLength) characters") + } + } + + /// Validates price is not null and non-negative. + /// - Parameter price: The price to validate + /// - Throws: ValidationError if validation fails + public static func validatePrice(_ price: Int64) throws { + guard price >= 0 else { + throw ValidationError.invalidValue("Price cannot be negative") + } + } + + /// Validates description does not exceed maximum length. + /// For required fields, caller should ensure description is not null before calling this method. + /// - Parameter description: The description to validate + /// - Throws: ValidationError if validation fails + public static func validateDescription(_ description: String) throws { + guard description.count <= Constants.kMaximumDescriptionLength else { + throw ValidationError.invalidLength("Description length longer than maximum allowed") + } + } + + /// Validates display name does not exceed maximum length. + /// For required fields, caller should ensure displayName is not null before calling this method. + /// - Parameter displayName: The display name to validate + /// - Throws: ValidationError if validation fails + public static func validateDisplayName(_ displayName: String) throws { + guard displayName.count <= Constants.kMaximumDisplayNameLength else { + throw ValidationError.invalidLength("DisplayName length longer than maximum allowed") + } + } + + /// Validates SKU does not exceed maximum length. + /// - Parameter sku: The SKU to validate + /// - Throws: ValidationError if validation fails + public static func validateSku(_ sku: String) throws { + guard sku.count <= Constants.kMaximumSkuLength else { + throw ValidationError.invalidLength("SKU length longer than maximum allowed") + } + } + + /// Validates SKU does not exceed maximum length. + /// - Parameter sku: The SKU to validate + /// - Throws: ValidationError if validation fails + public static func validateStorefront(_ storefront: String) throws { + guard storefront.count <= Constants.kMaximumStorefrontLength else { + throw ValidationError.invalidLength("Storefront length longer than maximum allowed") + } + } +} diff --git a/Sources/Mercato/Mercato+Extras.swift b/Sources/Mercato/Mercato+Extras.swift index 4bcc565..99df32f 100644 --- a/Sources/Mercato/Mercato+Extras.swift +++ b/Sources/Mercato/Mercato+Extras.swift @@ -153,21 +153,21 @@ extension Mercato { /// value. /// - Returns: An array of all the products received from the App Store. /// - Throws: `MercatoError` - public static func retrieveProducts(productIds: Set) async throws (MercatoError) -> [Product] { + public static func retrieveProducts(productIds: Set) async throws(MercatoError) -> [Product] { try await Mercato.shared.retrieveProducts(productIds: productIds) } /// Whether the user is eligible to have an introductory offer applied to a purchase in this /// subscription group. /// - Parameter productIds: Set of product ids. - public static func isEligibleForIntroOffer(for productIds: Set) async throws (MercatoError) -> Bool { + public static func isEligibleForIntroOffer(for productIds: Set) async throws(MercatoError) -> Bool { try await shared.isEligibleForIntroOffer(for: productIds) } /// Whether the user is eligible to have an introductory offer applied to a purchase in this /// subscription group. /// - Parameter productId: The product identifier to check eligibility for. - public static func isEligibleForIntroOffer(for productId: String) async throws (MercatoError) -> Bool { + public static func isEligibleForIntroOffer(for productId: String) async throws(MercatoError) -> Bool { try await shared.isEligibleForIntroOffer(for: productId) } @@ -176,7 +176,7 @@ extension Mercato { /// - Parameter product: The `Product` to check. /// - Returns: A Boolean value indicating whether the product has been purchased. /// - Throws: `MercatoError` if the purchase status could not be determined. - public static func isPurchased(_ product: Product) async throws (MercatoError) -> Bool { + public static func isPurchased(_ product: Product) async throws(MercatoError) -> Bool { try await shared.isPurchased(product) } @@ -185,7 +185,7 @@ extension Mercato { /// - Parameter productIdentifier: The identifier of the product to check. /// - Returns: A Boolean value indicating whether the product has been purchased. /// - Throws: `MercatoError` if the purchase status could not be determined. - public static func isPurchased(_ productIdentifier: String) async throws (MercatoError) -> Bool { + public static func isPurchased(_ productIdentifier: String) async throws(MercatoError) -> Bool { try await shared.isPurchased(productIdentifier) } @@ -208,7 +208,7 @@ extension Mercato { finishAutomatically: Bool = false, appAccountToken: UUID? = nil, simulatesAskToBuyInSandbox: Bool = false - ) async throws (MercatoError) -> Purchase { + ) async throws(MercatoError) -> Purchase { try await shared.purchase( productId: productId, promotionalOffer: promotionalOffer, @@ -238,7 +238,7 @@ extension Mercato { finishAutomatically: Bool = false, appAccountToken: UUID? = nil, simulatesAskToBuyInSandbox: Bool = false - ) async throws (MercatoError) -> Purchase { + ) async throws(MercatoError) -> Purchase { try await shared.purchase( product: product, promotionalOffer: promotionalOffer, @@ -262,7 +262,7 @@ extension Mercato { product: Product, options: Set, finishAutomatically: Bool - ) async throws (MercatoError) -> Purchase { + ) async throws(MercatoError) -> Purchase { try await shared.purchase(product: product, options: options, finishAutomatically: finishAutomatically) } } diff --git a/Sources/Mercato/Mercato+StoreKit.swift b/Sources/Mercato/Mercato+StoreKit.swift index 34e278f..fb47614 100644 --- a/Sources/Mercato/Mercato+StoreKit.swift +++ b/Sources/Mercato/Mercato+StoreKit.swift @@ -252,8 +252,8 @@ extension Product.SubscriptionOffer { } extension VerificationResult { - var payload: Transaction { - get throws (MercatoError) { + package var payload: Transaction { + get throws(MercatoError) { switch self { case .verified(let payload): return payload @@ -263,3 +263,16 @@ extension VerificationResult { } } } + +extension Product.PurchaseOption { + private enum Constants { + static let kAdvancedCommerceDataKey = "advancedCommerceData" + } + + public static func advancedCommerceData(_ data: Data) -> Product.PurchaseOption { + Product.PurchaseOption.custom( + key: Constants.kAdvancedCommerceDataKey, + value: data + ) + } +} diff --git a/Sources/Mercato/Mercato.swift b/Sources/Mercato/Mercato.swift index 16af03b..5320dee 100644 --- a/Sources/Mercato/Mercato.swift +++ b/Sources/Mercato/Mercato.swift @@ -136,13 +136,13 @@ extension Mercato { // MARK: - Mercato public final class Mercato: Sendable { - private let productService: ProductService + private let productService: any StoreKitProductService package convenience init() { self.init(productService: CachingProductService()) } - public init(productService: ProductService) { + public init(productService: any StoreKitProductService) { self.productService = productService } @@ -152,7 +152,7 @@ public final class Mercato: Sendable { /// value. /// - Returns: An array of all the products received from the App Store. /// - Throws: `MercatoError` - public func retrieveProducts(productIds: Set) async throws (MercatoError) -> [Product] { + public func retrieveProducts(productIds: Set) async throws(MercatoError) -> [Product] { try await productService.retrieveProducts(productIds: productIds) } @@ -162,14 +162,14 @@ public final class Mercato: Sendable { /// value. /// - Returns: An array of all the products received from the App Store. /// - Throws: `MercatoError` - public func retrieveSubscriptionProducts(productIds: Set) async throws (MercatoError) -> [Product] { + public func retrieveSubscriptionProducts(productIds: Set) async throws(MercatoError) -> [Product] { try await productService.retrieveProducts(productIds: productIds) } /// Whether the user is eligible to have an introductory offer applied to a purchase in this /// subscription group. /// - Parameter productIds: Set of product ids. - public func isEligibleForIntroOffer(for productIds: Set) async throws (MercatoError) -> Bool { + public func isEligibleForIntroOffer(for productIds: Set) async throws(MercatoError) -> Bool { let products = try await productService.retrieveProducts(productIds: productIds) guard let product = products.first else { @@ -188,7 +188,7 @@ public final class Mercato: Sendable { /// Whether the user is eligible to have an introductory offer applied to a purchase in this /// subscription group. /// - Parameter productId: The product identifier to check eligibility for. - public func isEligibleForIntroOffer(for productId: String) async throws (MercatoError) -> Bool { + public func isEligibleForIntroOffer(for productId: String) async throws(MercatoError) -> Bool { let products = try await productService.retrieveProducts(productIds: [productId]) guard let product = products.first else { @@ -207,7 +207,7 @@ public final class Mercato: Sendable { /// - Parameter product: The `Product` to check. /// - Returns: A Boolean value indicating whether the product has been purchased. /// - Throws: `MercatoError` if the purchase status could not be determined. - public func isPurchased(_ product: Product) async throws (MercatoError) -> Bool { + public func isPurchased(_ product: Product) async throws(MercatoError) -> Bool { try await productService.isPurchased(product) } @@ -216,7 +216,7 @@ public final class Mercato: Sendable { /// - Parameter productIdentifier: The identifier of the product to check. /// - Returns: A Boolean value indicating whether the product has been purchased. /// - Throws: `MercatoError` if the purchase status could not be determined. - public func isPurchased(_ productIdentifier: String) async throws (MercatoError) -> Bool { + public func isPurchased(_ productIdentifier: String) async throws(MercatoError) -> Bool { try await productService.isPurchased(productIdentifier) } @@ -304,7 +304,7 @@ extension Mercato { finishAutomatically: Bool = false, appAccountToken: UUID? = nil, simulatesAskToBuyInSandbox: Bool = false - ) async throws (MercatoError) -> Purchase { + ) async throws(MercatoError) -> Purchase { let products = try await productService.retrieveProducts(productIds: [productId]) guard let product = products.first else { @@ -339,7 +339,7 @@ extension Mercato { finishAutomatically: Bool = false, appAccountToken: UUID? = nil, simulatesAskToBuyInSandbox: Bool = false - ) async throws (MercatoError) -> Purchase { + ) async throws(MercatoError) -> Purchase { let options = PurchaseOptionsBuilder() .setQuantity(quantity) .setAppAccountToken(appAccountToken) @@ -363,7 +363,7 @@ extension Mercato { product: Product, options: Set, finishAutomatically: Bool - ) async throws (MercatoError) -> Purchase { + ) async throws(MercatoError) -> Purchase { do { let result = try await product.purchase(options: options) @@ -383,7 +383,7 @@ extension Mercato { _ result: Product.PurchaseResult, product: Product, finishAutomatically: Bool - ) async throws (MercatoError) -> Purchase { + ) async throws(MercatoError) -> Purchase { switch result { case .success(let verification): let transaction = try verification.payload @@ -439,7 +439,7 @@ extension Mercato { @available(iOS 15.0, visionOS 1.0, *) @available(watchOS, unavailable) @available(tvOS, unavailable) - public func beginRefundProcess(for productID: String, in displayContext: DisplayContext) async throws (MercatoError) { + public func beginRefundProcess(for productID: String, in displayContext: DisplayContext) async throws(MercatoError) { guard let result = await Transaction.latest(for: productID) else { throw MercatoError.noTransactionForSpecifiedProduct } diff --git a/Sources/Mercato/MercatoError.swift b/Sources/Mercato/MercatoError.swift index 3ffab54..91d4050 100644 --- a/Sources/Mercato/MercatoError.swift +++ b/Sources/Mercato/MercatoError.swift @@ -50,6 +50,8 @@ public enum MercatoError: Error, Sendable { case noSubscriptionInTheProduct + case productNotFound(String) + /// An unknown error occurred. case unknown(error: Error?) } @@ -77,10 +79,12 @@ extension MercatoError: LocalizedError { return "This error happens when product doesn't have subsciption" case .unknown(error: let error): return "Unknown error: \(String(describing: error)), Description: \(error?.localizedDescription ?? "Description is missing")" + case .productNotFound(let id): + return "This error happens when product for id = \(id) not found" } } - static func wrapped(error: any Error) -> MercatoError { + package static func wrapped(error: any Error) -> MercatoError { if let mercatoError = error as? MercatoError { return mercatoError } else if let storeKitError = error as? StoreKitError { diff --git a/Sources/Mercato/ProductService.swift b/Sources/Mercato/ProductService.swift index 7675bf6..359303a 100644 --- a/Sources/Mercato/ProductService.swift +++ b/Sources/Mercato/ProductService.swift @@ -25,6 +25,7 @@ import StoreKit // MARK: - ProductService public protocol ProductService: Sendable { + associatedtype ProductItem /// Requests product data from the App Store. /// - Parameter identifiers: A set of product identifiers to load from the App Store. If any /// identifiers are not found, they will be excluded from the return @@ -32,32 +33,55 @@ public protocol ProductService: Sendable { /// - Returns: An array of all the products received from the App Store. /// - Throws: `MercatoError` /// - func retrieveProducts(productIds: Set) async throws (MercatoError) -> [Product] + func retrieveProducts(productIds: Set) async throws(MercatoError) -> [ProductItem] +} + + +// MARK: - StoreKitProductService + +public protocol StoreKitProductService: ProductService where ProductItem == StoreKit.Product { /// Checks if a given product has been purchased. /// /// - Parameter product: The `Product` to check. /// - Returns: A Boolean value indicating whether the product has been purchased. /// - Throws: `MercatoError` if the purchase status could not be determined. - func isPurchased(_ product: Product) async throws (MercatoError) -> Bool + func isPurchased(_ product: StoreKit.Product) async throws(MercatoError) -> Bool /// Checks if a product with the given identifier has been purchased. /// /// - Parameter productIdentifier: The identifier of the product to check. /// - Returns: A Boolean value indicating whether the product has been purchased. /// - Throws: `MercatoError` if the purchase status could not be determined. - func isPurchased(_ productIdentifier: String) async throws (MercatoError) -> Bool + func isPurchased(_ productIdentifier: String) async throws(MercatoError) -> Bool } -/// A product service implementation with caching and request deduplication -package final class CachingProductService: ProductService, @unchecked Sendable { - private let lock = DefaultLock() - private var cachedProducts: [String: Product] = [:] - private var activeFetches: [Set: Task<[Product], Error>] = [:] - public func retrieveProducts(productIds: Set) async throws (MercatoError) -> [Product] { +// MARK: - FetchableProduct + +public protocol FetchableProduct { + var id: String { get } + + static func products(for identifiers: some Collection) async throws -> [Self] +} + +// MARK: - AbstractCachingProductService + +public class AbstractCachingProductService: ProductService, @unchecked Sendable { + public typealias ProductItem = P + + internal let lock = DefaultLock() + internal var cachedProducts: [String: ProductItem] = [:] + internal var activeFetches: [Set: Task<[ProductItem], Error>] = [:] + + public init() { } + + public func retrieveProducts(productIds: Set) async throws(MercatoError) -> [ProductItem] { lock.lock() - var cached: [Product] = [] + let cachedProducts = cachedProducts + lock.unlock() + + var cached: [ProductItem] = [] var missingIds = Set() for id in productIds { @@ -69,24 +93,28 @@ package final class CachingProductService: ProductService, @unchecked Sendable { } if missingIds.isEmpty { - lock.unlock() return cached } - if let existingTask = activeFetches[missingIds] { + return try await fetchProducts(productIds: missingIds) + cached + } + + internal func fetchProducts(productIds: Set) async throws(MercatoError) -> [ProductItem] { + lock.lock() + if let existingTask = activeFetches[productIds] { lock.unlock() do { let fetchedProducts = try await existingTask.value - return cached + fetchedProducts + return fetchedProducts } catch { throw MercatoError.wrapped(error: error) } } - let fetchTask = Task<[Product], Error> { - try await Product.products(for: missingIds) + let fetchTask = Task<[ProductItem], Error> { + try await ProductItem.products(for: productIds) } - activeFetches[missingIds] = fetchTask + activeFetches[productIds] = fetchTask lock.unlock() do { @@ -96,24 +124,27 @@ package final class CachingProductService: ProductService, @unchecked Sendable { for product in fetchedProducts { cachedProducts[product.id] = product } - activeFetches.removeValue(forKey: missingIds) + activeFetches.removeValue(forKey: productIds) lock.unlock() - return cached + fetchedProducts + return fetchedProducts } catch { lock.lock() - activeFetches.removeValue(forKey: missingIds) + activeFetches.removeValue(forKey: productIds) lock.unlock() + throw MercatoError.wrapped(error: error) } } +} +extension AbstractCachingProductService where ProductItem == StoreKit.Product { /// Checks if a given product has been purchased. /// /// - Parameter product: The `Product` to check. /// - Returns: A Boolean value indicating whether the product has been purchased. /// - Throws: `MercatoError` if the purchase status could not be determined. - public nonisolated func isPurchased(_ product: Product) async throws (MercatoError) -> Bool { + public nonisolated func isPurchased(_ product: Product) async throws(MercatoError) -> Bool { try await isPurchased(product.id) } @@ -122,7 +153,7 @@ package final class CachingProductService: ProductService, @unchecked Sendable { /// - Parameter productIdentifier: The identifier of the product to check. /// - Returns: A Boolean value indicating whether the product has been purchased. /// - Throws: `MercatoError` if the purchase status could not be determined. - public nonisolated func isPurchased(_ productIdentifier: String) async throws (MercatoError) -> Bool { + public nonisolated func isPurchased(_ productIdentifier: String) async throws(MercatoError) -> Bool { guard let result = await Transaction.latest(for: productIdentifier) else { return false } @@ -136,4 +167,6 @@ package final class CachingProductService: ProductService, @unchecked Sendable { } } - +public typealias CachingProductService = AbstractCachingProductService +extension CachingProductService: StoreKitProductService { } +extension StoreKit.Product: FetchableProduct { } diff --git a/Sources/Mercato/Utils/Lock.swift b/Sources/Mercato/Utils/Lock.swift index 09fed0a..6c19bbb 100644 --- a/Sources/Mercato/Utils/Lock.swift +++ b/Sources/Mercato/Utils/Lock.swift @@ -1,12 +1,16 @@ import Foundation import os +// MARK: - Lock + package protocol Lock: Sendable { func lock() func unlock() func run(_ closure: @Sendable () throws -> T) rethrows -> T } +// MARK: - DefaultLock + package final class DefaultLock: Lock { private nonisolated let defaultLock: Lock = { if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { @@ -31,6 +35,8 @@ package final class DefaultLock: Lock { } } +// MARK: - OSAUnfairLock + // MIT License // // Copyright (c) 2021-2025 Pavel T @@ -74,6 +80,8 @@ package final class OSAUnfairLock: Lock { } } +// MARK: - NSLock + Lock + extension NSLock: Lock { public func run(_ closure: @Sendable () throws -> T) rethrows -> T where T : Sendable { lock()