├── www ├── logo.png └── logo-social.png ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── ios-tests.yml ├── Example └── MercatoExample │ ├── MercatoExample │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── Info.plist │ ├── Base.lproj │ │ ├── Main.storyboard │ │ └── LaunchScreen.storyboard │ ├── AppDelegate.swift │ ├── ViewController.swift │ └── SceneDelegate.swift │ └── MercatoExample.xcodeproj │ └── project.xcworkspace │ └── xcshareddata │ └── swiftpm │ └── Package.resolved ├── LICENSE ├── Sources ├── AdvancedCommerceMercato │ ├── Models │ │ ├── RequestVersion.swift │ │ ├── Reason.swift │ │ ├── RequestOperation.swift │ │ ├── OfferReason.swift │ │ ├── Effective.swift │ │ ├── Period.swift │ │ ├── OfferPeriod.swift │ │ ├── SubscriptionModifyPeriodChange.swift │ │ ├── SubscriptionModifyRemoveItem.swift │ │ ├── SubscriptionReactivateItem.swift │ │ ├── Offer.swift │ │ ├── Descriptors.swift │ │ ├── SubscriptionModifyDescriptors.swift │ │ ├── RequestInfo.swift │ │ ├── OneTimeChargeItem.swift │ │ ├── SubscriptionCreateItem.swift │ │ ├── OneTimeChargeCreateRequest.swift │ │ ├── SubscriptionModifyAddItem.swift │ │ ├── SubscriptionModifyChangeItem.swift │ │ ├── SubscriptionReactivateInAppRequest.swift │ │ ├── SubscriptionCreateRequest.swift │ │ ├── ValidationUtils.swift │ │ └── SubscriptionModifyInAppRequest.swift │ ├── AdvancedCommercePurchase.swift │ ├── AdvancedCommerceProductService.swift │ └── Mercato+AdvancedCommerce.swift └── Mercato │ ├── Models │ └── PromotionalOffer.swift │ ├── Utils │ ├── PeriodFormatter.swift │ ├── Lock.swift │ ├── CurrencySymbolsLibrary.swift │ └── PriceFormatter.swift │ ├── Purchase.swift │ ├── MercatoError.swift │ ├── ProductService.swift │ └── Mercato+StoreKit.swift ├── Package.swift ├── Package@swift-5.10.swift ├── .swiftformat ├── Tests └── MercatoTests │ ├── PurchaseOptionsTests.swift │ ├── MercatoTests.swift │ ├── ProductServiceTests.swift │ ├── CurrencySymbolsLibraryTests.swift │ ├── TestHelper.swift │ ├── PeriodFormatterTests.swift │ └── ProductTests.swift ├── .gitignore ├── CHANGELOG.md └── README.md /www/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tikhop/Mercato/HEAD/www/logo.png -------------------------------------------------------------------------------- /www/logo-social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tikhop/Mercato/HEAD/www/logo-social.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [tikhop] 4 | -------------------------------------------------------------------------------- /Example/MercatoExample/MercatoExample/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/MercatoExample/MercatoExample/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Example/MercatoExample/MercatoExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "7e181c5637777fb3695a855d5045c2de4acd9d75d8e809ce1dc44a6fb426d2a7", 3 | "pins" : [ 4 | { 5 | "identity" : "mercato", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/tikhop/Mercato", 8 | "state" : { 9 | "revision" : "ffcc020f1247d3f12e07d943d780956ec84bd6cf", 10 | "version" : "1.0.0" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | ## Expected Behavior 13 | 14 | 15 | ## Current Behavior 16 | 17 | 18 | ## Possible Solution 19 | 20 | 21 | ## Alternatives Considered 22 | 23 | 24 | ## Additional Context 25 | 26 | -------------------------------------------------------------------------------- /Example/MercatoExample/MercatoExample/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "idiom" : "universal", 16 | "platform" : "ios", 17 | "size" : "1024x1024" 18 | }, 19 | { 20 | "appearances" : [ 21 | { 22 | "appearance" : "luminosity", 23 | "value" : "tinted" 24 | } 25 | ], 26 | "idiom" : "universal", 27 | "platform" : "ios", 28 | "size" : "1024x1024" 29 | } 30 | ], 31 | "info" : { 32 | "author" : "xcode", 33 | "version" : 1 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Example/MercatoExample/MercatoExample/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIApplicationSceneManifest 6 | 7 | UIApplicationSupportsMultipleScenes 8 | 9 | UISceneConfigurations 10 | 11 | UIWindowSceneSessionRoleApplication 12 | 13 | 14 | UISceneConfigurationName 15 | Default Configuration 16 | UISceneDelegateClassName 17 | $(PRODUCT_MODULE_NAME).SceneDelegate 18 | UISceneStoryboardFile 19 | Main 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2024 Pavel T 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | ## Expected Behavior 13 | 14 | 15 | ## Current Behavior 16 | 17 | 18 | ## Possible Solution 19 | 20 | 21 | ## Steps to Reproduce (for bugs) 22 | 23 | 24 | 1. 25 | 2. 26 | 3. 27 | 4. 28 | 29 | ## Context 30 | 31 | 32 | 33 | ## Your Environment 34 | 35 | * Version used: 36 | * Environment name and version (device, simalator): 37 | * Operating System and version (iOS, osx): 38 | * Link to your project: 39 | -------------------------------------------------------------------------------- /Sources/AdvancedCommerceMercato/Models/RequestVersion.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Foundation 24 | 25 | public enum RequestVersion: String, Codable { 26 | case v1 = "1" 27 | } 28 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ## Issue being fixed or feature implemented 5 | 6 | 7 | 8 | 9 | ## What was done? 10 | 11 | 12 | 13 | ## How Has This Been Tested? 14 | 15 | 16 | 17 | 18 | 19 | ## Breaking Changes 20 | 21 | 22 | 23 | 24 | ## Checklist: 25 | 26 | - [ ] I have performed a self-review of my own code 27 | - [ ] I have commented my code, particularly in hard-to-understand areas 28 | - [ ] I have added or updated relevant unit/integration/functional/e2e tests 29 | - [ ] I have made corresponding changes to the documentation 30 | 31 | -------------------------------------------------------------------------------- /Sources/AdvancedCommerceMercato/Models/Reason.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | /// 24 | public enum Reason: String, Codable, Hashable, Sendable { 25 | case upgrade = "UPGRADE" 26 | case downgrade = "DOWNGRADE" 27 | case applyOffer = "APPLY_OFFER" 28 | } 29 | -------------------------------------------------------------------------------- /Sources/AdvancedCommerceMercato/Models/RequestOperation.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Foundation 24 | 25 | public enum RequestOperation: String, Codable { 26 | case createSubscription = "CREATE_SUBSCRIPTION" 27 | case oneTimeCharge = "CREATE_ONE_TIME_CHARGE" 28 | case modifySubscription = "MODIFY_SUBSCRIPTION" 29 | case reactivateSubscription = "REACTIVATE_SUBSCRIPTION" 30 | } 31 | -------------------------------------------------------------------------------- /Sources/AdvancedCommerceMercato/Models/OfferReason.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | /// The reason for the offer. 24 | /// 25 | /// [Offer](https://developer.apple.com/documentation/advancedcommerceapi/offer) 26 | public enum OfferReason: String, Decodable, Encodable, Hashable, Sendable { 27 | case acquisition = "ACQUISITION" 28 | case winBack = "WIN_BACK" 29 | case retention = "RETENTION" 30 | } 31 | -------------------------------------------------------------------------------- /Sources/AdvancedCommerceMercato/Models/Effective.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | /// A string value that indicates when a requested change to an auto-renewable subscription goes into effect. 24 | /// 25 | /// [effective](https://developer.apple.com/documentation/advancedcommerceapi/effective) 26 | public enum Effective: String, Codable, Hashable, Sendable { 27 | case immediately = "IMMEDIATELY" 28 | case nextBillCycle = "NEXT_BILL_CYCLE" 29 | } 30 | -------------------------------------------------------------------------------- /Sources/AdvancedCommerceMercato/Models/Period.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | /// The duration of a single cycle of an auto-renewable subscription. 24 | /// 25 | /// [period](https://developer.apple.com/documentation/advancedcommerceapi/period) 26 | public enum Period: String, Decodable, Encodable, Hashable, Sendable { 27 | case p1w = "P1W" 28 | case p1m = "P1M" 29 | case p2m = "P2M" 30 | case p3m = "P3M" 31 | case p6m = "P6M" 32 | case p1y = "P1Y" 33 | } 34 | -------------------------------------------------------------------------------- /Sources/AdvancedCommerceMercato/Models/OfferPeriod.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | /// The period of the offer. 24 | /// 25 | /// [Offer](https://developer.apple.com/documentation/advancedcommerceapi/offer) 26 | public enum OfferPeriod: String, Decodable, Encodable, Hashable, Sendable { 27 | case p3d = "P3D" 28 | case p1w = "P1W" 29 | case p2w = "P2W" 30 | case p1m = "P1M" 31 | case p2m = "P2M" 32 | case p3m = "P3M" 33 | case p6m = "P6M" 34 | case p9m = "P9M" 35 | case p1y = "P1Y" 36 | } 37 | -------------------------------------------------------------------------------- /Example/MercatoExample/MercatoExample/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /Example/MercatoExample/MercatoExample/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Sources/AdvancedCommerceMercato/AdvancedCommercePurchase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // Mercato 4 | // 5 | // Created by PT on 8/26/25. 6 | // 7 | 8 | import StoreKit 9 | 10 | // MARK: - AdvancedCommercePurchase 11 | 12 | /// A wrapper around StoreKit's `AdvancedCommerceProduct` and `Transaction` objects, providing a convenient interfae for handling in-app purchases. 13 | @available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) 14 | public struct AdvancedCommercePurchase: Sendable { 15 | /// The product associated with the purchase. 16 | public let product: AdvancedCommerceProduct 17 | 18 | /// The result associated with the purchase. 19 | public let result: VerificationResult 20 | 21 | /// A flag indicating whether the transaction needs to be finished manually. 22 | public let needsFinishTransaction: Bool 23 | } 24 | 25 | @available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) 26 | extension AdvancedCommercePurchase { 27 | /// The transaction associated with the purchase. 28 | public var transaction: Transaction { 29 | result.unsafePayloadValue 30 | } 31 | 32 | /// The identifier of the purchased product. 33 | /// 34 | /// This is derived from the `productID` property of the `transaction` object. 35 | public var productId: String { 36 | product.id 37 | } 38 | 39 | /// The quantity of the purchased product. 40 | /// 41 | /// This is derived from the `purchasedQuantity` property of the `transaction` object. 42 | public var quantity: Int { 43 | transaction.purchasedQuantity 44 | } 45 | 46 | /// Completes the transaction by calling the `finish()` method on the `transaction` object. 47 | /// 48 | /// This should be called if `needsFinishTransaction` is `true`. 49 | public func finish() async { 50 | await transaction.finish() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/Mercato/Models/PromotionalOffer.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Foundation 24 | 25 | extension Mercato { 26 | /// Promotional offer for a purchase. 27 | public struct PromotionalOffer: Sendable { 28 | /// The `id` property of the `SubscriptionOffer` to apply. 29 | let offerID: String 30 | 31 | /// The key ID of the private key used to generate `signature`. 32 | /// The private key and key ID can be generated on App Store Connect. 33 | let keyID: String 34 | 35 | /// The nonce used in `signature`. 36 | let nonce: UUID 37 | 38 | /// The cryptographic signature of the offer parameters, generated on your server. 39 | let signature: Data 40 | 41 | /// The time the signature was generated in milliseconds since 1970. 42 | let timestamp: Int 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/AdvancedCommerceMercato/Models/SubscriptionModifyPeriodChange.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | 24 | import Foundation 25 | 26 | /// A period change for Advanced Commerce subscription modifications. 27 | /// 28 | /// [SubscriptionModifyPeriodChange](https://developer.apple.com/documentation/advancedcommerceapi/SubscriptionModifyPeriodChange) 29 | public struct SubscriptionModifyPeriodChange: Decodable, Encodable, Hashable, Sendable { 30 | 31 | /// When the modification takes effect. 32 | /// 33 | /// [Effective](https://developer.apple.com/documentation/advancedcommerceapi/effective) 34 | public var effective: Effective 35 | 36 | /// Period. 37 | /// 38 | /// [period](https://developer.apple.com/documentation/advancedcommerceapi/period) 39 | public var period: Period 40 | 41 | init(effective: Effective, period: Period) { 42 | self.effective = effective 43 | self.period = period 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/AdvancedCommerceMercato/Models/SubscriptionModifyRemoveItem.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | // MARK: - SubscriptionModifyRemoveItem 24 | 25 | /// An item for removing from Advanced Commerce subscription modifications. 26 | /// 27 | /// [SubscriptionModifyRemoveItem](https://developer.apple.com/documentation/advancedcommerceapi/SubscriptionModifyRemoveItem) 28 | public struct SubscriptionModifyRemoveItem: Decodable, Encodable { 29 | 30 | /// The SKU identifier for the item. 31 | /// 32 | /// [SKU](https://developer.apple.com/documentation/advancedcommerceapi/sku) 33 | public var sku: String 34 | 35 | init(sku: String) { 36 | self.sku = sku 37 | } 38 | 39 | public enum CodingKeys: String, CodingKey { 40 | case sku = "SKU" 41 | } 42 | } 43 | 44 | // MARK: Validatable 45 | 46 | extension SubscriptionModifyRemoveItem: Validatable { 47 | public func validate() throws { 48 | try ValidationUtils.validateSku(sku) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/AdvancedCommerceMercato/Models/SubscriptionReactivateItem.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | 24 | import Foundation 25 | 26 | // MARK: - SubscriptionReactivateItem 27 | 28 | /// An item for reactivating Advanced Commerce subscriptions. 29 | /// 30 | /// [SubscriptionReactivateItem](https://developer.apple.com/documentation/advancedcommerceapi/SubscriptionReactivateItem) 31 | public struct SubscriptionReactivateItem: Decodable, Encodable { 32 | 33 | /// The SKU identifier for the item. 34 | /// 35 | /// [SKU](https://developer.apple.com/documentation/advancedcommerceapi/sku) 36 | public var sku: String 37 | 38 | init(sku: String) { 39 | self.sku = sku 40 | } 41 | 42 | public enum CodingKeys: String, CodingKey { 43 | case sku = "SKU" 44 | } 45 | } 46 | 47 | // MARK: Validatable 48 | 49 | extension SubscriptionReactivateItem: Validatable { 50 | public func validate() throws { 51 | try ValidationUtils.validateSku(sku) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | // MIT License 4 | // 5 | // Copyright (c) 2021-2025 Pavel T 6 | // 7 | // Permission is hereby granted, free of charge, to any person obtaining a copy 8 | // of this software and associated documentation files (the "Software"), to deal 9 | // in the Software without restriction, including without limitation the rights 10 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | // copies of the Software, and to permit persons to whom the Software is 12 | // furnished to do so, subject to the following conditions: 13 | // 14 | // The above copyright notice and this permission notice shall be included in all 15 | // copies or substantial portions of the Software. 16 | // 17 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | // SOFTWARE. 24 | 25 | import PackageDescription 26 | 27 | let package = Package( 28 | name: "Mercato", 29 | platforms: [ 30 | .iOS("15.4"), .tvOS("17.0"), .watchOS("10.0"), .macOS("13.0"), .visionOS(.v1) 31 | ], 32 | products: [ 33 | .library( 34 | name: "Mercato", 35 | targets: ["Mercato"] 36 | ), 37 | .library( 38 | name: "AdvancedCommerceMercato", 39 | targets: ["AdvancedCommerceMercato"] 40 | ) 41 | ], 42 | dependencies: [], 43 | targets: [ 44 | .target(name: "Mercato"), 45 | .target( 46 | name: "AdvancedCommerceMercato", 47 | dependencies: [ 48 | .target(name: "Mercato") 49 | ] 50 | ), 51 | .testTarget( 52 | name: "MercatoTests", 53 | dependencies: [ 54 | .target(name: "Mercato"), 55 | .target(name: "AdvancedCommerceMercato") 56 | ], 57 | resources: [ 58 | .copy("Mercato.storekit") 59 | ] 60 | ) 61 | ] 62 | ) 63 | -------------------------------------------------------------------------------- /Package@swift-5.10.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.10 2 | 3 | // MIT License 4 | // 5 | // Copyright (c) 2021-2025 Pavel T 6 | // 7 | // Permission is hereby granted, free of charge, to any person obtaining a copy 8 | // of this software and associated documentation files (the "Software"), to deal 9 | // in the Software without restriction, including without limitation the rights 10 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | // copies of the Software, and to permit persons to whom the Software is 12 | // furnished to do so, subject to the following conditions: 13 | // 14 | // The above copyright notice and this permission notice shall be included in all 15 | // copies or substantial portions of the Software. 16 | // 17 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | // SOFTWARE. 24 | 25 | import PackageDescription 26 | 27 | let package = Package( 28 | name: "Mercato", 29 | platforms: [ 30 | .iOS("15.4"), .tvOS("17.0"), .watchOS("10.0"), .macOS("13.0"), .visionOS(.v1) 31 | ], 32 | products: [ 33 | .library( 34 | name: "Mercato", 35 | targets: ["Mercato"] 36 | ), 37 | .library( 38 | name: "AdvancedCommerceMercato", 39 | targets: ["AdvancedCommerceMercato"] 40 | ) 41 | ], 42 | dependencies: [], 43 | targets: [ 44 | .target(name: "Mercato"), 45 | .target( 46 | name: "AdvancedCommerceMercato", 47 | dependencies: [ 48 | .target(name: "Mercato") 49 | ] 50 | ), 51 | .testTarget( 52 | name: "MercatoTests", 53 | dependencies: [ 54 | .target(name: "Mercato"), 55 | .target(name: "AdvancedCommerceMercato") 56 | ], 57 | resources: [ 58 | .copy("Mercato.storekit") 59 | ] 60 | ) 61 | ] 62 | ) 63 | -------------------------------------------------------------------------------- /Sources/Mercato/Utils/PeriodFormatter.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Foundation 24 | 25 | public struct PeriodFormatter: Sendable { 26 | public static var formatter: DateComponentsFormatter { 27 | let formatter = DateComponentsFormatter() 28 | formatter.maximumUnitCount = 1 29 | formatter.unitsStyle = .full 30 | formatter.zeroFormattingBehavior = .dropAll 31 | return formatter 32 | } 33 | 34 | public static func format(unit: NSCalendar.Unit, numberOfUnits: Int) -> String? { 35 | var dateComponents = DateComponents() 36 | dateComponents.calendar = Calendar.current 37 | 38 | formatter.allowedUnits = [unit] 39 | 40 | switch unit { 41 | case .day: 42 | dateComponents.setValue(numberOfUnits, for: .day) 43 | case .weekOfMonth: 44 | dateComponents.setValue(numberOfUnits, for: .weekOfMonth) 45 | case .month: 46 | dateComponents.setValue(numberOfUnits, for: .month) 47 | case .year: 48 | dateComponents.setValue(numberOfUnits, for: .year) 49 | default: 50 | return nil 51 | } 52 | 53 | return formatter.string(from: dateComponents) 54 | } 55 | } 56 | 57 | -------------------------------------------------------------------------------- /Sources/AdvancedCommerceMercato/Models/Offer.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Foundation 24 | 25 | // MARK: - Offer 26 | 27 | /// A discount offer for an auto-renewable subscription. 28 | /// 29 | /// [Offer](https://developer.apple.com/documentation/advancedcommerceapi/offer) 30 | public struct Offer: Codable { 31 | 32 | /// The period of the offer. 33 | public var period: OfferPeriod 34 | 35 | /// The number of periods the offer is active. 36 | public var periodCount: Int32 37 | 38 | /// The offer price, in milliunits. 39 | /// 40 | /// [Price](https://developer.apple.com/documentation/advancedcommerceapi/price) 41 | public var price: Int64 42 | 43 | /// The reason for the offer. 44 | public var reason: OfferReason 45 | 46 | public init( 47 | period: OfferPeriod, 48 | periodCount: Int32, 49 | price: Int64, 50 | reason: OfferReason 51 | ) { 52 | self.period = period 53 | self.periodCount = periodCount 54 | self.price = price 55 | self.reason = reason 56 | } 57 | 58 | } 59 | 60 | // MARK: Validatable 61 | 62 | extension Offer: Validatable { 63 | public func validate() throws { 64 | try ValidationUtils.validatePrice(price) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/AdvancedCommerceMercato/Models/Descriptors.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Foundation 24 | 25 | // MARK: - Descriptors 26 | 27 | /// The description and display name of the subscription to migrate to that you manage. 28 | public struct Descriptors: Decodable, Encodable { 29 | /// A string you provide that describes a SKU. 30 | /// 31 | /// [Description](https://developer.apple.com/documentation/appstoreserverapi/description) 32 | public var description: String 33 | 34 | /// A string with a product name that you can localize and is suitable for display to customers. 35 | /// 36 | /// [DisplayName](https://developer.apple.com/documentation/appstoreserverapi/displayname) 37 | public var displayName: String 38 | 39 | public init(description: String, displayName: String) { 40 | self.description = description 41 | self.displayName = displayName 42 | } 43 | 44 | public enum CodingKeys: String, CodingKey { 45 | case description 46 | case displayName 47 | } 48 | } 49 | 50 | // MARK: Validatable 51 | 52 | extension Descriptors: Validatable { 53 | public func validate() throws { 54 | try ValidationUtils.validateDescription(description) 55 | try ValidationUtils.validateDisplayName(displayName) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.github/workflows/ios-tests.yml: -------------------------------------------------------------------------------- 1 | name: iOS build and test 2 | 3 | on: 4 | pull_request: 5 | branches: [ "master" ] 6 | 7 | jobs: 8 | build: 9 | name: Build and Test default scheme using any available iPhone simulator 10 | runs-on: macos-15 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | - name: Set Default Scheme 16 | run: | 17 | scheme_list=$(xcodebuild -list -json | tr -d "\n") 18 | default=$(echo $scheme_list | ruby -e "require 'json'; puts JSON.parse(STDIN.gets)['project']['targets'][0]") 19 | echo $default | cat >default 20 | echo Using default scheme: $default 21 | - name: Build 22 | env: 23 | scheme: ${{ 'default' }} 24 | platform: ${{ 'iOS Simulator' }} 25 | run: | 26 | # xcrun xctrace returns via stderr, not the expected stdout (see https://developer.apple.com/forums/thread/663959) 27 | device=`xcrun xctrace list devices 2>&1 | grep -oE 'iPhone.*?[^\(]+' | head -1 | awk '{$1=$1;print}' | sed -e "s/ Simulator$//"` 28 | if [ $scheme = default ]; then scheme=$(cat default); fi 29 | if [ "`ls -A | grep -i \\.xcworkspace\$`" ]; then filetype_parameter="workspace" && file_to_build="`ls -A | grep -i \\.xcworkspace\$`"; else filetype_parameter="project" && file_to_build="`ls -A | grep -i \\.xcodeproj\$`"; fi 30 | file_to_build=`echo $file_to_build | awk '{$1=$1;print}'` 31 | xcodebuild build-for-testing -scheme "$scheme" -"$filetype_parameter" "$file_to_build" -destination "platform=$platform,name=$device" 32 | - name: Test 33 | env: 34 | scheme: ${{ 'default' }} 35 | platform: ${{ 'iOS Simulator' }} 36 | run: | 37 | # xcrun xctrace returns via stderr, not the expected stdout (see https://developer.apple.com/forums/thread/663959) 38 | device=`xcrun xctrace list devices 2>&1 | grep -oE 'iPhone.*?[^\(]+' | head -1 | awk '{$1=$1;print}' | sed -e "s/ Simulator$//"` 39 | if [ $scheme = default ]; then scheme=$(cat default); fi 40 | if [ "`ls -A | grep -i \\.xcworkspace\$`" ]; then filetype_parameter="workspace" && file_to_build="`ls -A | grep -i \\.xcworkspace\$`"; else filetype_parameter="project" && file_to_build="`ls -A | grep -i \\.xcodeproj\$`"; fi 41 | file_to_build=`echo $file_to_build | awk '{$1=$1;print}'` 42 | xcodebuild test-without-building -scheme "$scheme" -"$filetype_parameter" "$file_to_build" -destination "platform=$platform,name=$device" 43 | -------------------------------------------------------------------------------- /Example/MercatoExample/MercatoExample/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import UIKit 24 | 25 | @main 26 | class AppDelegate: UIResponder, UIApplicationDelegate { 27 | 28 | 29 | 30 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 31 | // Override point for customization after application launch. 32 | true 33 | } 34 | 35 | // MARK: UISceneSession Lifecycle 36 | 37 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 38 | // Called when a new scene session is being created. 39 | // Use this method to select a configuration to create the new scene with. 40 | UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 41 | } 42 | 43 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 44 | // Called when the user discards a scene session. 45 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 46 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 47 | } 48 | 49 | 50 | } 51 | 52 | -------------------------------------------------------------------------------- /Sources/Mercato/Purchase.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import StoreKit 24 | 25 | // MARK: - Purchase 26 | 27 | /// A wrapper around StoreKit's `Product` and `Transaction` objects, providing a convenient interface for handling in-app purchases. 28 | public struct Purchase: Sendable { 29 | /// The product associated with the purchase. 30 | public let product: Product 31 | 32 | /// The result associated with the purchase. 33 | public let result: VerificationResult 34 | 35 | /// A flag indicating whether the transaction needs to be finished manually. 36 | public let needsFinishTransaction: Bool 37 | } 38 | 39 | extension Purchase { 40 | /// The transaction associated with the purchase. 41 | public var transaction: Transaction { 42 | result.unsafePayloadValue 43 | } 44 | 45 | /// The identifier of the purchased product. 46 | /// 47 | /// This is derived from the `productID` property of the `transaction` object. 48 | public var productId: String { 49 | product.id 50 | } 51 | 52 | /// The quantity of the purchased product. 53 | /// 54 | /// This is derived from the `purchasedQuantity` property of the `transaction` object. 55 | public var quantity: Int { 56 | transaction.purchasedQuantity 57 | } 58 | 59 | /// Completes the transaction by calling the `finish()` method on the `transaction` object. 60 | /// 61 | /// This should be called if `needsFinishTransaction` is `true`. 62 | public func finish() async { 63 | await transaction.finish() 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | # options 2 | --swiftversion 6.0 3 | --self remove # redundantSelf 4 | --importgrouping testable-bottom # sortedImports 5 | --commas inline # trailingCommas 6 | --trimwhitespace always # trailingSpace 7 | --indent 4 #indent 8 | --ifdef no-indent #indent 9 | --indentstrings true #indent 10 | 11 | --wraparguments before-first # wrapArguments 12 | --wrapparameters before-first # wrapArguments 13 | --wrapcollections before-first # wrapArguments 14 | --wrapconditions after-first # wrapArguments 15 | --wrapreturntype preserve #wrapArguments 16 | --closingparen balanced # wrapArguments 17 | --wraptypealiases before-first # wrapArguments 18 | --funcattributes prev-line # wrapAttributes 19 | --typeattributes prev-line # wrapAttributes 20 | --wrapternary before-operators # wraprations 21 | --extensionacl on-declarations # extensionAccessControl 22 | --patternlet inline # hoistPatternLet 23 | --redundanttype inferred # redundantType 24 | --typeblanklines preserve # blankLinesAtStartOfScope, blankLinesAtEndOfScope 25 | --emptybraces spaced # emptyBraces 26 | --someAny disabled # opaqueGenericParameters 27 | 28 | # We recommend a max width of 100 but _strictly enforce_ a max width of 130 29 | --maxwidth 180 # wrap 30 | 31 | --stripunusedargs closure-only #unusedArguments 32 | 33 | # rules 34 | --rules anyObjectProtocol 35 | --rules blankLinesBetweenScopes 36 | --rules consecutiveSpaces 37 | --rules duplicateImports 38 | --rules extensionAccessControl 39 | --rules hoistPatternLet 40 | --rules indent 41 | --rules markTypes 42 | --rules redundantParens 43 | --rules redundantReturn 44 | --rules redundantSelf 45 | --rules redundantType 46 | --rules redundantPattern 47 | --rules redundantGet 48 | --rules redundantFileprivate 49 | --rules redundantRawValues 50 | --rules sortedImports 51 | --rules sortDeclarations 52 | --rules strongifiedSelf 53 | --rules trailingCommas 54 | --rules trailingSpace 55 | --rules typeSugar 56 | --rules wrap 57 | --rules wrapArguments 58 | --rules wrapAttributes 59 | --rules braces 60 | --rules redundantClosure 61 | --rules redundantInit 62 | --rules redundantVoidReturnType 63 | --rules redundantOptionalBinding 64 | --rules unusedArguments 65 | --rules spaceInsideBrackets 66 | --rules spaceInsideBraces 67 | --rules spaceAroundBraces 68 | --rules spaceInsideParens 69 | --rules spaceAroundParens 70 | --rules enumNamespaces 71 | --rules blockComments 72 | --rules spaceAroundComments 73 | --rules spaceInsideComments 74 | --rules blankLinesAtStartOfScope 75 | --rules blankLinesAtEndOfScope 76 | --rules emptyBraces 77 | --rules opaqueGenericParameters 78 | --rules genericExtensions 79 | --rules trailingClosures 80 | 81 | 82 | --rules anyObjectProtocol 83 | --rules assertionFailures 84 | --rules blankLineAfterImports 85 | --rules isEmpty 86 | --rules leadingDelimiters 87 | --rules wrapEnumCases 88 | -------------------------------------------------------------------------------- /Sources/AdvancedCommerceMercato/Models/SubscriptionModifyDescriptors.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | 24 | import Foundation 25 | 26 | // MARK: - SubscriptionModifyDescriptors 27 | 28 | /// Descriptors for Advanced Commerce subscription modifications. 29 | /// 30 | /// [SubscriptionModifyDescriptors](https://developer.apple.com/documentation/advancedcommerceapi/SubscriptionModifyDescriptors) 31 | public struct SubscriptionModifyDescriptors: Codable, Hashable, Sendable { 32 | /// The description of the item. 33 | /// 34 | /// [Description](https://developer.apple.com/documentation/advancedcommerceapi/description) 35 | public var description: String? 36 | 37 | /// The display name of the item. 38 | /// 39 | /// [Display Name](https://developer.apple.com/documentation/advancedcommerceapi/displayname) 40 | public var displayName: String? 41 | 42 | /// When the modification takes effect. 43 | /// 44 | /// [Effective](https://developer.apple.com/documentation/advancedcommerceapi/effective) 45 | public var effective: Effective 46 | 47 | init(description: String? = nil, displayName: String? = nil, effective: Effective) { 48 | self.description = description 49 | self.displayName = displayName 50 | self.effective = effective 51 | } 52 | } 53 | 54 | // MARK: Validatable 55 | 56 | extension SubscriptionModifyDescriptors: Validatable { 57 | public func validate() throws { 58 | if let description { try ValidationUtils.validateDescription(description) } 59 | if let displayName { try ValidationUtils.validateDisplayName(displayName) } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Tests/MercatoTests/PurchaseOptionsTests.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import StoreKit 24 | import StoreKitTest 25 | import Testing 26 | @testable import Mercato 27 | 28 | @Suite("Purchase Options") 29 | struct PurchaseOptionsTests { 30 | 31 | @Test("PurchaseOptionsBuilder builds options") 32 | func testPurchaseOptionsBuilder() { 33 | let builder = Mercato.PurchaseOptionsBuilder() 34 | let options = builder.build() 35 | 36 | // Just verify it builds without crashing 37 | #expect(options.count >= 2) // At least quantity and simulatesAskToBuy 38 | } 39 | 40 | @Test("PurchaseOptionsBuilder with custom values") 41 | func testPurchaseOptionsBuilderCustomValues() { 42 | let appToken = UUID() 43 | let builder = Mercato.PurchaseOptionsBuilder() 44 | .setQuantity(5) 45 | .setAppAccountToken(appToken) 46 | .setSimulatesAskToBuyInSandbox(true) 47 | 48 | let options = builder.build() 49 | 50 | // Just verify it builds with more options 51 | #expect(options.count >= 3) // Should have at least 3 options set 52 | } 53 | 54 | @Test("PromotionalOffer structure") 55 | func testPromotionalOfferStructure() { 56 | let offer = Mercato.PromotionalOffer( 57 | offerID: "TEST_OFFER", 58 | keyID: "KEY123", 59 | nonce: UUID(), 60 | signature: Data(), 61 | timestamp: 1234567890 62 | ) 63 | 64 | #expect(offer.offerID == "TEST_OFFER") 65 | #expect(offer.keyID == "KEY123") 66 | #expect(offer.timestamp == 1234567890) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | 92 | .DS_Store 93 | .swiftpm 94 | /.build 95 | /Packages 96 | /*.xcodeproj 97 | xcuserdata/ 98 | DerivedData/ 99 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 100 | .idea 101 | /TODO 102 | CLAUDE.md 103 | .claude 104 | -------------------------------------------------------------------------------- /Sources/AdvancedCommerceMercato/Models/RequestInfo.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Foundation 24 | 25 | // MARK: - RequestInfo 26 | 27 | /// The metadata to include in server requests. 28 | /// 29 | /// [RequestInfo](https://developer.apple.com/documentation/advancedcommerceapi/requestinfo) 30 | public struct RequestInfo: Decodable, Encodable, Hashable, Sendable { 31 | /// The app account token for the request. 32 | /// 33 | /// [App Account Token](https://developer.apple.com/documentation/advancedcommerceapi/appaccounttoken) 34 | public var appAccountToken: UUID? 35 | 36 | /// The consistency token for the request. 37 | /// 38 | /// [Consistency Token](https://developer.apple.com/documentation/advancedcommerceapi/consistencytoken) 39 | public var consistencyToken: String? 40 | 41 | /// The request reference identifier. 42 | /// 43 | /// [Request Reference ID](https://developer.apple.com/documentation/advancedcommerceapi/requestreferenceid) 44 | public var requestReferenceId: UUID 45 | 46 | public init(appAccountToken: UUID? = nil, consistencyToken: String? = nil, requestReferenceId: UUID) { 47 | self.appAccountToken = appAccountToken 48 | self.consistencyToken = consistencyToken 49 | self.requestReferenceId = requestReferenceId 50 | } 51 | 52 | public enum CodingKeys: CodingKey { 53 | case appAccountToken 54 | case consistencyToken 55 | case requestReferenceId 56 | } 57 | } 58 | 59 | // MARK: Validatable 60 | 61 | extension RequestInfo: Validatable { 62 | public func validate() throws { 63 | if let appAccountToken { try ValidationUtils.validUUID(appAccountToken) } 64 | try ValidationUtils.validUUID(requestReferenceId) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/Mercato/Utils/Lock.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import os 3 | 4 | // MARK: - Lock 5 | 6 | package protocol Lock: Sendable { 7 | func lock() 8 | func unlock() 9 | func run(_ closure: @Sendable () throws -> T) rethrows -> T 10 | } 11 | 12 | // MARK: - DefaultLock 13 | 14 | package final class DefaultLock: Lock { 15 | private nonisolated let defaultLock: Lock = { 16 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { 17 | return OSAUnfairLock() 18 | } else { 19 | return NSLock() 20 | } 21 | }() 22 | 23 | public init() { } 24 | 25 | public func lock() { 26 | defaultLock.lock() 27 | } 28 | 29 | public func unlock() { 30 | defaultLock.unlock() 31 | } 32 | 33 | public func run(_ closure: @Sendable () throws -> T) rethrows -> T where T: Sendable { 34 | try defaultLock.run(closure) 35 | } 36 | } 37 | 38 | // MARK: - OSAUnfairLock 39 | 40 | // MIT License 41 | // 42 | // Copyright (c) 2021-2025 Pavel T 43 | // 44 | // Permission is hereby granted, free of charge, to any person obtaining a copy 45 | // of this software and associated documentation files (the "Software"), to deal 46 | // in the Software without restriction, including without limitation the rights 47 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 48 | // copies of the Software, and to permit persons to whom the Software is 49 | // furnished to do so, subject to the following conditions: 50 | // 51 | // The above copyright notice and this permission notice shall be included in all 52 | // copies or substantial portions of the Software. 53 | // 54 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 55 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 56 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 57 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 58 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 59 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 60 | // SOFTWARE. 61 | 62 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 63 | package final class OSAUnfairLock: Lock { 64 | private let unfairLock = OSAllocatedUnfairLock() 65 | 66 | public init() { } 67 | 68 | public func lock() { 69 | unfairLock.lock() 70 | } 71 | 72 | public func unlock() { 73 | unfairLock.unlock() 74 | } 75 | 76 | public func run(_ closure: @Sendable () throws -> T) rethrows -> T { 77 | try unfairLock.withLock { 78 | try closure() 79 | } 80 | } 81 | } 82 | 83 | // MARK: - NSLock + Lock 84 | 85 | extension NSLock: Lock { 86 | public func run(_ closure: @Sendable () throws -> T) rethrows -> T where T : Sendable { 87 | lock() 88 | let v = try closure() 89 | unlock() 90 | return v 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Sources/AdvancedCommerceMercato/AdvancedCommerceProductService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // Mercato 4 | // 5 | // Created by PT on 8/26/25. 6 | // 7 | 8 | import Foundation 9 | import Mercato 10 | import StoreKit 11 | 12 | // MARK: - AdvancedCommerceProductService 13 | 14 | @available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) 15 | public protocol AdvancedCommerceProductService: ProductService where ProductItem == AdvancedCommerceProduct { 16 | func latestTransaction(for productId: AdvancedCommerceProduct.ID) async -> VerificationResult? 17 | func allTransactions(for productId: AdvancedCommerceProduct.ID) async -> Transaction.Transactions? 18 | func currentEntitlements(for productId: AdvancedCommerceProduct.ID) async -> Transaction.Transactions? 19 | } 20 | 21 | @available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) 22 | public typealias AdvancedCommerceCachingProductService = AbstractCachingProductService 23 | 24 | // MARK: - AdvancedCommerceCachingProductService + AdvancedCommerceProductService 25 | 26 | @available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) 27 | extension AdvancedCommerceCachingProductService: AdvancedCommerceProductService { 28 | public func latestTransaction(for productId: AdvancedCommerceProduct.ID) async -> VerificationResult? { 29 | guard let product = try? await retrieveProducts(productIds: [productId]).first else { 30 | return nil 31 | } 32 | 33 | return await product.latestTransaction 34 | } 35 | 36 | public func allTransactions(for productId: AdvancedCommerceProduct.ID) async -> Transaction.Transactions? { 37 | guard let product = try? await retrieveProducts(productIds: [productId]).first else { 38 | return nil 39 | } 40 | 41 | return product.allTransactions 42 | } 43 | 44 | public func currentEntitlements(for productId: AdvancedCommerceProduct.ID) async -> Transaction.Transactions? { 45 | guard let product = try? await retrieveProducts(productIds: [productId]).first else { 46 | return nil 47 | } 48 | 49 | return product.currentEntitlements 50 | } 51 | } 52 | 53 | // MARK: - AdvancedCommerceProduct + FetchableProduct 54 | 55 | @available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) 56 | extension AdvancedCommerceProduct: FetchableProduct { 57 | public static func products(for identifiers: some Collection) async throws -> [AdvancedCommerceProduct] { 58 | try await withThrowingTaskGroup(of: AdvancedCommerceProduct.self) { group in 59 | for id in identifiers { 60 | group.addTask { 61 | try await AdvancedCommerceProduct(id: id) 62 | } 63 | } 64 | 65 | var products: [AdvancedCommerceProduct] = [] 66 | for try await product in group { 67 | products.append(product) 68 | } 69 | 70 | return products 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/Mercato/Utils/CurrencySymbolsLibrary.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Foundation 24 | 25 | public final class CurrencySymbolsLibrary: @unchecked Sendable { 26 | private var symbols: [String: String] 27 | private let lock: DefaultLock 28 | 29 | init() { 30 | symbols = [:] 31 | lock = DefaultLock() 32 | } 33 | 34 | public func symbol(for code: String) -> String? { 35 | lock.lock() 36 | defer { lock.unlock() } 37 | 38 | if let symbol = cachedSymbol(for: code.uppercased()) { 39 | return symbol 40 | } 41 | 42 | let symbol = nativeCurrencySymbol(for: code.uppercased()) 43 | symbols[code] = symbol 44 | 45 | return symbol 46 | } 47 | 48 | private func cachedSymbol(for code: String) -> String? { 49 | if let symbol = symbols[code] { 50 | return symbol 51 | } 52 | 53 | return nil 54 | } 55 | 56 | private func nativeCurrencySymbol(for currencyCode: String) -> String? { 57 | var shortestSymbol: String? 58 | 59 | for identifier in Locale.availableIdentifiers { 60 | let locale = Locale(identifier: identifier) 61 | let currencyIdentifier: String? 62 | if #available(iOS 16, *) { 63 | currencyIdentifier = locale.currency?.identifier 64 | } else { 65 | currencyIdentifier = locale.currencyCode 66 | } 67 | if currencyIdentifier == currencyCode, 68 | let symbol = locale.currencySymbol { 69 | if shortestSymbol == nil || symbol.count < shortestSymbol!.count { 70 | shortestSymbol = symbol 71 | } 72 | } 73 | } 74 | 75 | guard let shortestSymbol else { 76 | return nil 77 | } 78 | 79 | return shortestSymbol.isEmpty ? nil : shortestSymbol 80 | } 81 | 82 | public static let shared = CurrencySymbolsLibrary() 83 | } 84 | -------------------------------------------------------------------------------- /Sources/AdvancedCommerceMercato/Models/OneTimeChargeItem.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | // MARK: - OneTimeChargeItem 24 | 25 | /// The details of a one-time charge product, including its display name, price, SKU, and metadata. 26 | /// 27 | /// [OneTimeChargeItem](https://developer.apple.com/documentation/advancedcommerceapi/onetimechargeitem) 28 | public struct OneTimeChargeItem: Decodable, Encodable { 29 | 30 | /// The product identifier. 31 | /// 32 | /// [SKU](https://developer.apple.com/documentation/advancedcommerceapi/sku) 33 | public var sku: String 34 | 35 | /// A description of the product that doesn’t display to customers. 36 | /// 37 | /// [description](https://developer.apple.com/documentation/advancedcommerceapi/description) 38 | public var description: String 39 | 40 | /// The product name, suitable for display to customers. 41 | /// 42 | /// [displayName](https://developer.apple.com/documentation/advancedcommerceapi/displayName) 43 | public var displayName: String 44 | 45 | /// The price, in milliunits of the currency, of the one-time charge product. 46 | /// 47 | /// [Price](https://developer.apple.com/documentation/advancedcommerceapi/price) 48 | public var price: Int64 49 | 50 | public init(sku: String, description: String, displayName: String, price: Int64) { 51 | self.sku = sku 52 | self.description = description 53 | self.displayName = displayName 54 | self.price = price 55 | } 56 | 57 | public enum CodingKeys: String, CodingKey { 58 | case sku = "SKU" 59 | case description 60 | case displayName 61 | case price 62 | } 63 | } 64 | 65 | // MARK: Validatable 66 | 67 | extension OneTimeChargeItem: Validatable { 68 | public func validate() throws { 69 | try ValidationUtils.validateSku(sku) 70 | try ValidationUtils.validateDescription(description) 71 | try ValidationUtils.validateDisplayName(displayName) 72 | try ValidationUtils.validatePrice(price) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | Mercato 1.1.0 5 | --------- 6 | 7 | ### Added 8 | * Advanced Commerce API support 9 | - `AdvancedCommerceMercato` module for working with Advanced Commerce API 10 | - Request models for `OneTimeChargeCreateRequest`, `SubscriptionCreateRequest`, `SubscriptionModifyInAppRequest`, and `SubscriptionReactivateInAppRequest` 11 | 12 | Mercato 1.0.1 13 | --------- 14 | 15 | ### Fixed 16 | * Compilation issue on WatchOS 17 | * Example iOS application issue 18 | 19 | Mercato 1.0.0 20 | --------- 21 | 22 | ### Added 23 | * Subscription state monitoring for billing retry and grace period (addresses [#6](https://github.com/tikhop/Mercato/issues/6)) 24 | - `isInBillingRetry(for:)` - Check if subscription is in billing retry state 25 | - `isInGracePeriod(for:)` - Check if subscription is in grace period 26 | - `renewalState(for:)` - Get current renewal state for a subscription 27 | - `subscriptionStatusUpdates` - Direct access to StoreKit 2's subscription status update stream 28 | - `allSubscriptionStatuses` - Stream of all subscription group statuses (iOS 17+) 29 | * Comprehensive product extension methods for subscription details (localizedPrice, localizedPeriod, hasTrial, priceInDay) 30 | * PurchaseOptionsBuilder for fluent configuration of purchase options 31 | * PromotionalOffer model for handling subscription offers 32 | * ProductService with better caching for product fetching 33 | * PriceFormatter and PeriodFormatter utilities for localized formatting 34 | * CurrencySymbolsLibrary for comprehensive currency symbol support 35 | * Example iOS application demonstrating usage 36 | * Detailed usage documentation in Documentation/Usage.md 37 | * Support for visionOS platform 38 | 39 | ### Updated 40 | * Migrated to Swift 6.0 tools version while maintaining Swift 5.10 compatibility 41 | * Reorganized code structure 42 | * Enhanced error handling with more specific MercatoError cases 43 | * Simplified purchase API with automatic option building 44 | * Enhanced README with comprehensive usage examples 45 | 46 | Mercato 0.0.1-0.0.3 47 | --------- 48 | 49 | * Listen for transaction updates. If your app has unfinished transactions, you receive them immediately after the app launches 50 | ```swift 51 | Mercato.listenForTransactions(finishAutomatically: false) { transaction in 52 | //Deliver content to the user. 53 | 54 | //Finish transaction 55 | await transaction.finish() 56 | } 57 | ``` 58 | 59 | * Fetch products for the given set of product's ids 60 | ```swift 61 | do 62 | { 63 | let productIds: Set = ["com.test.product.1", "com.test.product.2", "com.test.product.3"] 64 | let products = try await Mercato.retrieveProducts(productIds: productIds) 65 | 66 | //Show products to the user 67 | }catch{ 68 | //Handle errors 69 | } 70 | ``` 71 | 72 | * Purchase a product 73 | ```swift 74 | try await Mercato.purchase(product: product, quantity: 1, finishAutomatically: false, appAccountToken: nil, simulatesAskToBuyInSandbox: false) 75 | ``` 76 | 77 | * Offering in-app refunds 78 | 79 | ```swift 80 | try await Mercato.beginRefundProcess(for: product, in: windowScene) 81 | ``` 82 | 83 | * Restore completed transactions 84 | 85 | ```swift 86 | try await Mercato.restorePurchases() 87 | ``` 88 | 89 | -------------------------------------------------------------------------------- /Sources/AdvancedCommerceMercato/Models/SubscriptionCreateItem.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Foundation 24 | 25 | // MARK: - SubscriptionCreateItem 26 | 27 | /// The data that describes a subscription item. 28 | /// 29 | /// [Advanced Commerce API Documentation](https://developer.apple.com/documentation/advancedcommerceapi/SubscriptionCreateItem) 30 | public struct SubscriptionCreateItem: Decodable, Encodable { 31 | 32 | /// The SKU identifier for the item. 33 | /// 34 | /// [SKU](https://developer.apple.com/documentation/advancedcommerceapi/sku) 35 | public var sku: String 36 | 37 | /// The description of the item. 38 | /// 39 | /// [Description](https://developer.apple.com/documentation/advancedcommerceapi/description) 40 | public var description: String 41 | 42 | /// The display name of the item. 43 | /// 44 | /// [Display Name](https://developer.apple.com/documentation/advancedcommerceapi/displayname) 45 | public var displayName: String 46 | 47 | /// The number of periods for billing. 48 | /// 49 | /// [Offer](https://developer.apple.com/documentation/advancedcommerceapi/offer) 50 | public var offer: Offer? 51 | 52 | /// The price in milliunits. 53 | /// 54 | /// [Price](https://developer.apple.com/documentation/advancedcommerceapi/price) 55 | public var price: Int64 56 | 57 | 58 | public init( 59 | sku: String, 60 | description: String, 61 | displayName: String, 62 | offer: Offer?, 63 | price: Int64 64 | ) { 65 | self.sku = sku 66 | self.description = description 67 | self.displayName = displayName 68 | self.offer = offer 69 | self.price = price 70 | } 71 | 72 | public enum CodingKeys: String, CodingKey { 73 | case sku = "SKU" 74 | case description 75 | case displayName 76 | case offer 77 | case price 78 | } 79 | } 80 | 81 | // MARK: Validatable 82 | 83 | extension SubscriptionCreateItem: Validatable { 84 | public func validate() throws { 85 | try ValidationUtils.validateSku(sku) 86 | try ValidationUtils.validateDescription(description) 87 | try ValidationUtils.validateDisplayName(displayName) 88 | try ValidationUtils.validatePrice(price) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Example/MercatoExample/MercatoExample/ViewController.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Mercato 24 | import UIKit 25 | 26 | class ViewController: UIViewController { 27 | 28 | private var storeKitUpdatesTask: Task? 29 | 30 | private func startObservingStoreKitUpdates() { 31 | storeKitUpdatesTask = Task.detached(priority: .high) { 32 | // First, let's finish unfinished txs 33 | for await verificationResult in Mercato.unfinishedTransactions { 34 | if case .verified(let tx) = verificationResult { 35 | // TODO: Send tx.jsonRepresentation to your server and deliver content to your user. 36 | await tx.finish() 37 | } 38 | } 39 | 40 | // Second, subscribe for updates 41 | for await verificationResult in Mercato.updates { 42 | if case .verified(let tx) = verificationResult { 43 | // TODO: Send tx.jsonRepresentation to your server and deliver content to your user. 44 | await tx.finish() 45 | } 46 | } 47 | } 48 | } 49 | 50 | private func configureHierarchy() { 51 | let button = UIButton(type: .system) 52 | button.translatesAutoresizingMaskIntoConstraints = false 53 | button.setTitle("Buy", for: .normal) 54 | button.addTarget(self, action: #selector(buyDidTap), for: .touchUpInside) 55 | view.addSubview(button) 56 | 57 | NSLayoutConstraint.activate([ 58 | button.centerXAnchor.constraint(equalTo: view.centerXAnchor), 59 | button.centerYAnchor.constraint(equalTo: view.centerYAnchor) 60 | ]) 61 | } 62 | 63 | @objc 64 | private func buyDidTap() { 65 | Task { 66 | let products = try await Mercato.retrieveProducts(productIds: ["product1", "product2"]) 67 | guard let firstProduct = products.first else { 68 | return 69 | } 70 | 71 | try await Mercato.purchase(product: firstProduct) 72 | } 73 | } 74 | 75 | override func viewDidLoad() { 76 | super.viewDidLoad() 77 | 78 | startObservingStoreKitUpdates() 79 | configureHierarchy() 80 | } 81 | 82 | deinit { 83 | storeKitUpdatesTask?.cancel() 84 | storeKitUpdatesTask = nil 85 | } 86 | } 87 | 88 | -------------------------------------------------------------------------------- /Example/MercatoExample/MercatoExample/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import UIKit 24 | 25 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 26 | 27 | var window: UIWindow? 28 | 29 | 30 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 31 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 32 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 33 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 34 | guard let _ = (scene as? UIWindowScene) else { return } 35 | } 36 | 37 | func sceneDidDisconnect(_ scene: UIScene) { 38 | // Called as the scene is being released by the system. 39 | // This occurs shortly after the scene enters the background, or when its session is discarded. 40 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 41 | // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). 42 | } 43 | 44 | func sceneDidBecomeActive(_ scene: UIScene) { 45 | // Called when the scene has moved from an inactive state to an active state. 46 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 47 | } 48 | 49 | func sceneWillResignActive(_ scene: UIScene) { 50 | // Called when the scene will move from an active state to an inactive state. 51 | // This may occur due to temporary interruptions (ex. an incoming phone call). 52 | } 53 | 54 | func sceneWillEnterForeground(_ scene: UIScene) { 55 | // Called as the scene transitions from the background to the foreground. 56 | // Use this method to undo the changes made on entering the background. 57 | } 58 | 59 | func sceneDidEnterBackground(_ scene: UIScene) { 60 | // Called as the scene transitions from the foreground to the background. 61 | // Use this method to save data, release shared resources, and store enough scene-specific state information 62 | // to restore the scene back to its current state. 63 | } 64 | 65 | 66 | } 67 | 68 | -------------------------------------------------------------------------------- /Sources/AdvancedCommerceMercato/Models/OneTimeChargeCreateRequest.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Foundation 24 | 25 | // MARK: - OneTimeChargeCreateRequest 26 | 27 | /// The request data your app provides when a customer purchases a one-time-charge product. 28 | /// 29 | /// [OneTimeChargeCreateRequest](https://developer.apple.com/documentation/advancedcommerceapi/onetimechargecreaterequest) 30 | public struct OneTimeChargeCreateRequest: Codable { 31 | 32 | /// The operation type for this request. 33 | public var operation: String = RequestOperation.oneTimeCharge.rawValue 34 | 35 | /// The version of this request. 36 | public var version: String = RequestVersion.v1.rawValue 37 | 38 | /// The metadata to include in server requests. 39 | /// 40 | /// [requestInfo](https://developer.apple.com/documentation/advancedcommerceapi/requestinfo) 41 | public var requestInfo: RequestInfo 42 | 43 | /// The currency of the price of the product. 44 | /// 45 | /// [currency](https://developer.apple.com/documentation/advancedcommerceapi/currency) 46 | public var currency: String 47 | 48 | /// The details of the product for purchase. 49 | /// 50 | /// [OneTimeChargeItem](https://developer.apple.com/documentation/advancedcommerceapi/onetimechargeitem) 51 | public var item: OneTimeChargeItem 52 | 53 | /// The storefront for the transaction. 54 | /// 55 | /// [storefront](https://developer.apple.com/documentation/advancedcommerceapi/onetimechargecreaterequest) 56 | public var storefront: String? 57 | 58 | /// The tax code for this product. 59 | /// 60 | /// [taxCode](https://developer.apple.com/documentation/advancedcommerceapi/onetimechargecreaterequest) 61 | public var taxCode: String 62 | 63 | /// Convenience initializer 64 | public init( 65 | currency: String, 66 | item: OneTimeChargeItem, 67 | requestInfo: RequestInfo, 68 | taxCode: String, 69 | storefront: String? = nil 70 | ) { 71 | self.requestInfo = requestInfo 72 | self.currency = currency 73 | self.item = item 74 | self.taxCode = taxCode 75 | self.storefront = storefront 76 | } 77 | } 78 | 79 | // MARK: Validatable 80 | 81 | extension OneTimeChargeCreateRequest: Validatable { 82 | public func validate() throws { 83 | try requestInfo.validate() 84 | 85 | try ValidationUtils.validateCurrency(currency) 86 | try ValidationUtils.validateTaxCode(taxCode) 87 | if let storefront { try ValidationUtils.validateStorefront(storefront) } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Sources/AdvancedCommerceMercato/Models/SubscriptionModifyAddItem.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | // MARK: - SubscriptionModifyAddItem 24 | 25 | /// An item for adding to Advanced Commerce subscription modifications. 26 | /// 27 | /// [SubscriptionModifyAddItem](https://developer.apple.com/documentation/advancedcommerceapi/SubscriptionModifyAddItem) 28 | public struct SubscriptionModifyAddItem: Decodable, Encodable { 29 | 30 | public init( 31 | sku: String, 32 | description: String, 33 | displayName: String, 34 | offer: Offer? = nil, 35 | price: Int64, 36 | proratedPrice: Int64? 37 | ) { 38 | self.sku = sku 39 | self.description = description 40 | self.displayName = displayName 41 | self.offer = offer 42 | self.price = price 43 | self.proratedPrice = proratedPrice 44 | } 45 | 46 | /// The SKU identifier for the item. 47 | /// 48 | /// [SKU](https://developer.apple.com/documentation/advancedcommerceapi/sku) 49 | public var sku: String 50 | 51 | /// The description of the item. 52 | /// 53 | /// [Description](https://developer.apple.com/documentation/advancedcommerceapi/description) 54 | public var description: String 55 | 56 | /// The display name of the item. 57 | /// 58 | /// [Display Name](https://developer.apple.com/documentation/advancedcommerceapi/displayname) 59 | public var displayName: String 60 | 61 | /// Offer. 62 | /// 63 | /// [offer](https://developer.apple.com/documentation/advancedcommerceapi/offer) 64 | public var offer: Offer? 65 | 66 | /// The price, in milliunits of the currency, of the one-time charge product. 67 | /// 68 | /// [Price](https://developer.apple.com/documentation/advancedcommerceapi/price) 69 | public var price: Int64 70 | 71 | /// The price, in milliunits of the currency, of the one-time charge product. 72 | /// 73 | /// [proratedPrice](https://developer.apple.com/documentation/advancedcommerceapi/proratedPrice) 74 | public var proratedPrice: Int64? 75 | 76 | public enum CodingKeys: String, CodingKey { 77 | case sku = "SKU" 78 | case description 79 | case displayName 80 | case offer 81 | case price 82 | case proratedPrice 83 | } 84 | } 85 | 86 | // MARK: Validatable 87 | 88 | extension SubscriptionModifyAddItem: Validatable { 89 | public func validate() throws { 90 | try offer?.validate() 91 | 92 | try ValidationUtils.validateSku(sku) 93 | try ValidationUtils.validateDescription(description) 94 | try ValidationUtils.validateDisplayName(displayName) 95 | try ValidationUtils.validatePrice(price) 96 | 97 | if let proratedPrice { try ValidationUtils.validatePrice(proratedPrice) } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Sources/Mercato/Utils/PriceFormatter.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Foundation 24 | 25 | @inline(__always) 26 | private let kDefaultFormatter: NumberFormatter = { 27 | let formatter = NumberFormatter() 28 | return formatter 29 | }() 30 | 31 | public extension Decimal { 32 | func formattedPrice( 33 | locale: Locale = .current, 34 | currencyCode: String, 35 | applyingRounding: Bool = false 36 | ) -> String? { 37 | var formatter = kDefaultFormatter 38 | 39 | formatter.numberStyle = .currency 40 | 41 | if locale != .current { 42 | formatter = formatter.copy() as! NumberFormatter 43 | formatter.locale = locale 44 | } else { 45 | formatter.locale = .current 46 | } 47 | 48 | formatter.currencyCode = currencyCode 49 | 50 | // Set native currency symbol if available 51 | if let currencySymbol = CurrencySymbolsLibrary.shared.symbol(for: currencyCode) { 52 | formatter.currencySymbol = currencySymbol 53 | } 54 | 55 | if applyingRounding { 56 | formatter = formatter == kDefaultFormatter ? formatter.copy() as! NumberFormatter : formatter 57 | formatter.roundingMode = .up 58 | formatter.maximumFractionDigits = 0 59 | } 60 | 61 | return formatter.string(from: NSDecimalNumber(decimal: self)) 62 | } 63 | 64 | 65 | @available(iOS 16, *) 66 | func formattedPrice( 67 | currencyStyle: Decimal.FormatStyle.Currency, 68 | locale: Locale = .current, 69 | applyingRounding: Bool = false 70 | ) -> String { 71 | var style = currencyStyle.locale(locale) 72 | 73 | if applyingRounding { 74 | style = style 75 | .precision(.fractionLength(0)) 76 | .rounded(rule: .up) 77 | } 78 | 79 | let formatted = self.formatted(style) 80 | 81 | let originalCurrencyCode = currencyStyle.locale.currency?.identifier ?? "" 82 | 83 | if let originalCurrencyCode = currencyStyle.locale.currency?.identifier, 84 | let customSymbol = CurrencySymbolsLibrary.shared.symbol(for: originalCurrencyCode), 85 | !originalCurrencyCode.isEmpty { 86 | var result = formatted.replacingOccurrences(of: originalCurrencyCode, with: customSymbol) 87 | 88 | if result.hasPrefix(customSymbol) { 89 | let symbolEndIndex = result.index(result.startIndex, offsetBy: customSymbol.count) 90 | 91 | // Remove any whitespace characters after the symbol 92 | while result.indices.contains(symbolEndIndex) && result[symbolEndIndex].isWhitespace { 93 | result.remove(at: symbolEndIndex) 94 | } 95 | } 96 | 97 | return result 98 | } 99 | 100 | return formatted 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Sources/Mercato/MercatoError.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Foundation 24 | import StoreKit 25 | 26 | // MARK: - MercatoError 27 | 28 | /// `MercatoError` categorizes errors related to StoreKit operations, purchases, and other potential issues 29 | /// that might arise during the execution 30 | public enum MercatoError: Error, Sendable { 31 | /// An error originating from StoreKit operations. 32 | case storeKit(error: StoreKitError) 33 | 34 | /// An error related to the purchase process. 35 | case purchase(error: Product.PurchaseError) 36 | 37 | /// An error related to the refund process. 38 | case refund(error: Transaction.RefundRequestError) 39 | 40 | /// An error indicating that the user canceled the action.. 41 | case canceledByUser 42 | 43 | /// An error indicating that the purchase is pending. 44 | case purchaseIsPending 45 | 46 | /// An error indicating that the verification of a purchase failed. 47 | case failedVerification(payload: Transaction, error: VerificationResult.VerificationError) 48 | 49 | case noTransactionForSpecifiedProduct 50 | 51 | case noSubscriptionInTheProduct 52 | 53 | case productNotFound(String) 54 | 55 | /// An unknown error occurred. 56 | case unknown(error: Error?) 57 | } 58 | 59 | // MARK: LocalizedError 60 | 61 | extension MercatoError: LocalizedError { 62 | public var errorDescription: String? { 63 | switch self { 64 | case .storeKit(error: let error): 65 | return error.errorDescription 66 | case .purchase(error: let error): 67 | return error.errorDescription 68 | case .refund(error: let error): 69 | return error.errorDescription 70 | case .canceledByUser: 71 | return "This error happens when user cancels purchase flow" 72 | case .purchaseIsPending: 73 | return "This error happens when purchase in pending flow (e.g. parental control is on)" 74 | case .failedVerification: 75 | return "This error happens when system is unable to verify Store Kit transaction" 76 | case .noTransactionForSpecifiedProduct: 77 | return "This error happens when system is unable to retrieve transaction for specified product" 78 | case .noSubscriptionInTheProduct: 79 | return "This error happens when product doesn't have subsciption" 80 | case .unknown(error: let error): 81 | return "Unknown error: \(String(describing: error)), Description: \(error?.localizedDescription ?? "Description is missing")" 82 | case .productNotFound(let id): 83 | return "This error happens when product for id = \(id) not found" 84 | } 85 | } 86 | 87 | package static func wrapped(error: any Error) -> MercatoError { 88 | if let mercatoError = error as? MercatoError { 89 | return mercatoError 90 | } else if let storeKitError = error as? StoreKitError { 91 | if case .userCancelled = storeKitError { 92 | return MercatoError.canceledByUser 93 | } 94 | 95 | return MercatoError.storeKit(error: storeKitError) 96 | 97 | } else if let error = error as? Product.PurchaseError { 98 | return MercatoError.purchase(error: error) 99 | } else { 100 | return MercatoError.unknown(error: error) 101 | } 102 | } 103 | } 104 | 105 | -------------------------------------------------------------------------------- /Tests/MercatoTests/MercatoTests.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import StoreKit 24 | import StoreKitTest 25 | import Testing 26 | @testable import Mercato 27 | 28 | @Suite("Mercato API Tests") 29 | struct MercatoTests { 30 | let sharedSession = TestSession.shared 31 | let mercato = Mercato() 32 | 33 | @Test("Static product retrieval") 34 | func testStaticProductRetrieval() async throws { 35 | let products = try await Mercato.retrieveProducts(productIds: [TestProducts.monthlyBasic, TestProducts.annualBasic]) 36 | 37 | #expect(products.count == 2) 38 | #expect(products.contains { $0.id == TestProducts.monthlyBasic }) 39 | #expect(products.contains { $0.id == TestProducts.annualBasic }) 40 | } 41 | 42 | @Test("Static eligibility check") 43 | func testStaticEligibilityCheck() async throws { 44 | let isEligible = try await Mercato.isEligibleForIntroOffer(for: TestProducts.monthlyIntro) 45 | 46 | // New users should be eligible 47 | #expect(isEligible == true) 48 | } 49 | 50 | @Test("Static purchase status check") 51 | func testStaticPurchaseStatus() async throws { 52 | let isPurchased = try await Mercato.isPurchased(TestProducts.removeAds) 53 | #expect(isPurchased == false) 54 | } 55 | 56 | @Test("Instance product retrieval") 57 | func testInstanceProductRetrieval() async throws { 58 | let products = try await mercato.retrieveProducts(productIds: [TestProducts.coins100, TestProducts.coins500]) 59 | 60 | #expect(products.count == 2) 61 | } 62 | 63 | @Test("Instance eligibility check for set") 64 | func testInstanceEligibilityCheckForSet() async throws { 65 | let isEligible = try await mercato.isEligibleForIntroOffer(for: [TestProducts.monthlyIntro, TestProducts.annualIntro]) 66 | 67 | // Should check eligibility for the subscription group 68 | #expect(isEligible == true || isEligible == false) 69 | } 70 | 71 | @Test("Instance purchase status by product") 72 | func testInstancePurchaseStatusByProduct() async throws { 73 | let products = try await mercato.retrieveProducts(productIds: [TestProducts.lifetimeAccess]) 74 | 75 | guard let product = products.first else { 76 | Issue.record("Failed to retrieve product") 77 | return 78 | } 79 | 80 | let isPurchased = try await mercato.isPurchased(product) 81 | #expect(isPurchased == false) 82 | } 83 | 84 | @Test("Instance purchase status by ID") 85 | func testInstancePurchaseStatusByID() async throws { 86 | let isPurchased = try await mercato.isPurchased(TestProducts.premiumFeatures) 87 | #expect(isPurchased == false) 88 | } 89 | 90 | @Test("Invalid product ID returns empty array") 91 | func testInvalidProductID() async throws { 92 | let products = try await mercato.retrieveProducts(productIds: ["com.invalid.product.xyz"]) 93 | 94 | #expect(products.isEmpty) 95 | } 96 | 97 | @Test("Empty product set returns empty array") 98 | func testEmptyProductSet() async throws { 99 | let products = try await mercato.retrieveProducts(productIds: []) 100 | #expect(products.isEmpty) 101 | } 102 | 103 | @Test("Purchase status for non-existent product") 104 | func testPurchaseStatusNonExistent() async throws { 105 | let isPurchased = try await mercato.isPurchased("com.invalid.product") 106 | #expect(isPurchased == false) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Sources/AdvancedCommerceMercato/Models/SubscriptionModifyChangeItem.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | // MARK: - SubscriptionModifyChangeItem 24 | 25 | /// An item for changing Advanced Commerce subscription modifications. 26 | /// 27 | /// [SubscriptionModifyChangeItem](https://developer.apple.com/documentation/advancedcommerceapi/SubscriptionModifyChangeItem) 28 | public struct SubscriptionModifyChangeItem: Codable { 29 | 30 | /// The SKU identifier for the item. 31 | /// 32 | /// [SKU](https://developer.apple.com/documentation/advancedcommerceapi/sku) 33 | public var sku: String 34 | 35 | /// The SKU identifier for the item. 36 | /// 37 | /// [SKU](https://developer.apple.com/documentation/advancedcommerceapi/sku) 38 | public var currentSku: String 39 | 40 | /// The description of the item. 41 | /// 42 | /// [Description](https://developer.apple.com/documentation/advancedcommerceapi/description) 43 | public var description: String 44 | 45 | /// The display name of the item. 46 | /// 47 | /// [Display Name](https://developer.apple.com/documentation/advancedcommerceapi/displayname) 48 | public var displayName: String 49 | 50 | /// When the modification takes effect. 51 | /// 52 | /// [Effective](https://developer.apple.com/documentation/advancedcommerceapi/effective) 53 | public var effective: Effective 54 | 55 | /// Offer. 56 | /// 57 | /// [offer](https://developer.apple.com/documentation/advancedcommerceapi/offer) 58 | public var offer: Offer? 59 | 60 | /// The price, in milliunits of the currency, of the one-time charge product. 61 | /// 62 | /// [Price](https://developer.apple.com/documentation/advancedcommerceapi/price) 63 | public var price: Int64 64 | 65 | /// The price, in milliunits of the currency, of the one-time charge product. 66 | /// 67 | /// [proratedPrice](https://developer.apple.com/documentation/advancedcommerceapi/proratedPrice) 68 | public var proratedPrice: Int64? 69 | 70 | /// Reason 71 | /// 72 | /// [Reason](Reason) 73 | public var reason: Reason 74 | 75 | init( 76 | sku: String, 77 | currentSku: String, 78 | description: String, 79 | displayName: String, 80 | effective: Effective, 81 | offer: Offer? = nil, 82 | price: Int64, 83 | proratedPrice: Int64? = nil, 84 | reason: Reason 85 | ) { 86 | self.sku = sku 87 | self.currentSku = currentSku 88 | self.description = description 89 | self.displayName = displayName 90 | self.effective = effective 91 | self.offer = offer 92 | self.price = price 93 | self.proratedPrice = proratedPrice 94 | self.reason = reason 95 | } 96 | 97 | public enum CodingKeys: String, CodingKey { 98 | case sku = "SKU" 99 | case currentSku = "currentSKU" 100 | case description 101 | case displayName 102 | case effective 103 | case offer 104 | case price 105 | case proratedPrice 106 | case reason 107 | } 108 | } 109 | 110 | // MARK: Validatable 111 | 112 | extension SubscriptionModifyChangeItem: Validatable { 113 | public func validate() throws { 114 | try offer?.validate() 115 | 116 | try ValidationUtils.validateSku(sku) 117 | try ValidationUtils.validateSku(currentSku) 118 | try ValidationUtils.validateDescription(description) 119 | try ValidationUtils.validateDisplayName(displayName) 120 | try ValidationUtils.validatePrice(price) 121 | 122 | if let proratedPrice { try ValidationUtils.validatePrice(proratedPrice) } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Sources/AdvancedCommerceMercato/Models/SubscriptionReactivateInAppRequest.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | 24 | import Foundation 25 | 26 | // MARK: - SubscriptionReactivateInAppRequest 27 | 28 | /// The request data your app provides to reactivate an auto-renewable subscription. 29 | /// 30 | /// [SubscriptionReactivateInAppRequest](https://developer.apple.com/documentation/advancedcommerceapi/subscriptionreactivateinapprequest) 31 | public struct SubscriptionReactivateInAppRequest: Decodable, Encodable { 32 | 33 | /// The operation type for this request. 34 | public var operation: String = RequestOperation.reactivateSubscription.rawValue 35 | 36 | /// The version of this request. 37 | public var version: String = RequestVersion.v1.rawValue 38 | 39 | /// The metadata to include in server requests. 40 | /// 41 | /// [requestInfo](https://developer.apple.com/documentation/advancedcommerceapi/requestinfo) 42 | public var requestInfo: RequestInfo 43 | 44 | /// The list of items to reactivate in the subscription. 45 | /// 46 | /// [SubscriptionReactivateItem](https://developer.apple.com/documentation/advancedcommerceapi/subscriptionreactivateitem) 47 | public var items: [SubscriptionReactivateItem]? 48 | 49 | /// 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. 50 | /// Include either the transactionId or the notificationType in your query, but not both. 51 | /// 52 | /// [transactionId](https://developer.apple.com/documentation/appstoreserverapi/transactionid) 53 | public var transactionId: String 54 | 55 | /// The storefront for the transaction. 56 | /// 57 | /// [storefront](https://developer.apple.com/documentation/advancedcommerceapi/onetimechargecreaterequest) 58 | public var storefront: String? 59 | 60 | public init( 61 | requestInfo: RequestInfo, 62 | items: [SubscriptionReactivateItem]? = nil, 63 | transactionId: String, 64 | storefront: String? = nil 65 | ) { 66 | self.requestInfo = requestInfo 67 | self.items = items 68 | self.transactionId = transactionId 69 | self.storefront = storefront 70 | } 71 | 72 | public enum CodingKeys: String, CodingKey, CaseIterable { 73 | case operation 74 | case version 75 | case requestInfo 76 | case transactionId 77 | case items 78 | case storefront 79 | } 80 | } 81 | 82 | // MARK: SubscriptionReactivateInAppRequest Builder 83 | extension SubscriptionReactivateInAppRequest { 84 | public func items(_ items: [SubscriptionReactivateItem]) -> SubscriptionReactivateInAppRequest { 85 | var updated = self 86 | updated.items = items 87 | return updated 88 | } 89 | 90 | public func addItem(_ item: SubscriptionReactivateItem) -> SubscriptionReactivateInAppRequest { 91 | var updated = self 92 | if updated.items == nil { 93 | updated.items = [] 94 | } 95 | updated.items?.append(item) 96 | return updated 97 | } 98 | 99 | public func storefront(_ storefront: String) -> SubscriptionReactivateInAppRequest { 100 | var updated = self 101 | updated.storefront = storefront 102 | return updated 103 | } 104 | } 105 | 106 | extension SubscriptionReactivateInAppRequest: Validatable { 107 | public func validate() throws { 108 | try requestInfo.validate() 109 | 110 | try ValidationUtils.validateTransactionId(transactionId) 111 | 112 | if let items { try items.forEach { try $0.validate() } } 113 | if let storefront { try ValidationUtils.validateStorefront(storefront) } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Sources/AdvancedCommerceMercato/Models/SubscriptionCreateRequest.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Foundation 24 | 25 | // MARK: - SubscriptionCreateRequest 26 | 27 | /// The metadata your app provides when a customer purchases an auto-renewable subscription. 28 | /// 29 | /// [SubscriptionCreateRequest](https://developer.apple.com/documentation/advancedcommerceapi/subscriptioncreaterequest) 30 | public struct SubscriptionCreateRequest: Decodable, Encodable { 31 | 32 | /// The operation type for this request. 33 | public var operation: String = RequestOperation.createSubscription.rawValue 34 | 35 | /// The version of this request. 36 | public var version: String = RequestVersion.v1.rawValue 37 | 38 | /// The currency of the price of the product. 39 | /// 40 | /// [currency](https://developer.apple.com/documentation/advancedcommerceapi/currency) 41 | public var currency: String 42 | 43 | /// The display name and description of a subscription product. 44 | /// 45 | /// [Descriptors](https://developer.apple.com/documentation/advancedcommerceapi/descriptors) 46 | public var descriptors: Descriptors 47 | 48 | /// The details of the subscription product for purchase. 49 | /// 50 | /// [SubscriptionCreateItem](https://developer.apple.com/documentation/advancedcommerceapi/subscriptioncreateitem) 51 | public var items: [SubscriptionCreateItem] 52 | 53 | /// The duration of a single cycle of an auto-renewable subscription. 54 | /// 55 | /// [period](https://developer.apple.com/documentation/advancedcommerceapi/period) 56 | public var period: Period 57 | 58 | /// The identifier of a previous transaction for the subscription. 59 | /// 60 | /// [transactionId](https://developer.apple.com/documentation/advancedcommerceapi/transactionid) 61 | public var previousTransactionId: String? 62 | 63 | /// The metadata to include in server requests. 64 | /// 65 | /// [requestInfo](https://developer.apple.com/documentation/advancedcommerceapi/requestinfo) 66 | public var requestInfo: RequestInfo 67 | 68 | /// The storefront for the transaction. 69 | /// 70 | /// [storefront](https://developer.apple.com/documentation/advancedcommerceapi/onetimechargecreaterequest) 71 | public var storefront: String? 72 | 73 | /// The tax code for this product. 74 | /// 75 | /// [taxCode](https://developer.apple.com/documentation/advancedcommerceapi/onetimechargecreaterequest) 76 | public var taxCode: String 77 | 78 | public init( 79 | currency: String, 80 | descriptors: Descriptors, 81 | items: [SubscriptionCreateItem], 82 | period: Period, 83 | previousTransactionId: String? = nil, 84 | requestInfo: RequestInfo, 85 | storefront: String? = nil, 86 | taxCode: String 87 | ) { 88 | self.currency = currency 89 | self.descriptors = descriptors 90 | self.items = items 91 | self.period = period 92 | self.previousTransactionId = previousTransactionId 93 | self.requestInfo = requestInfo 94 | self.storefront = nil 95 | self.taxCode = taxCode 96 | } 97 | 98 | 99 | public enum CodingKeys: String, CodingKey, CaseIterable { 100 | case operation 101 | case version 102 | case currency 103 | case descriptors 104 | case items 105 | case period 106 | case previousTransactionId 107 | case requestInfo 108 | case storefront 109 | case taxCode 110 | } 111 | } 112 | 113 | // MARK: Validatable 114 | 115 | extension SubscriptionCreateRequest: Validatable { 116 | public func validate() throws { 117 | try descriptors.validate() 118 | try requestInfo.validate() 119 | 120 | try items.forEach { try $0.validate() } 121 | try ValidationUtils.validateCurrency(currency) 122 | try ValidationUtils.validateTaxCode(taxCode) 123 | 124 | if let storefront { try ValidationUtils.validateStorefront(storefront) } 125 | if let previousTransactionId { try ValidationUtils.validateTransactionId(previousTransactionId) } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Tests/MercatoTests/ProductServiceTests.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import StoreKit 24 | import StoreKitTest 25 | import Testing 26 | @testable import Mercato 27 | 28 | @Suite("ProductService Tests") 29 | struct ProductServiceTests { 30 | let session = TestSession.shared 31 | let productService = CachingProductService() 32 | let mercato = Mercato() 33 | 34 | @Test("Retrieves single product successfully") 35 | func testRetrieveSingleProduct() async throws { 36 | let products = try await productService.retrieveProducts(productIds: [TestProducts.monthlyBasic]) 37 | 38 | #expect(products.count == 1) 39 | #expect(products.first?.id == TestProducts.monthlyBasic) 40 | } 41 | 42 | @Test("Retrieves multiple products") 43 | func testRetrieveMultipleProducts() async throws { 44 | let productIds = Set([ 45 | TestProducts.monthlyBasic, 46 | TestProducts.annualBasic, 47 | TestProducts.weeklyBasic 48 | ]) 49 | 50 | let products = try await productService.retrieveProducts(productIds: productIds) 51 | 52 | #expect(products.count == 3) 53 | #expect(products.contains { $0.id == TestProducts.monthlyBasic }) 54 | #expect(products.contains { $0.id == TestProducts.annualBasic }) 55 | #expect(products.contains { $0.id == TestProducts.weeklyBasic }) 56 | } 57 | 58 | @Test("Returns empty array for non-existent products") 59 | func testNonExistentProducts() async throws { 60 | let products = try await productService.retrieveProducts(productIds: ["com.nonexistent.product"]) 61 | 62 | #expect(products.isEmpty) 63 | } 64 | 65 | @Test("Uses cache for repeated requests") 66 | func testCacheUsage() async throws { 67 | let productIds = Set([TestProducts.monthlyBasic, TestProducts.annualBasic]) 68 | 69 | // First retrieval - fetches from StoreKit 70 | let firstProducts = try await productService.retrieveProducts(productIds: productIds) 71 | #expect(firstProducts.count == 2) 72 | 73 | // Second retrieval - should use cache 74 | let secondProducts = try await productService.retrieveProducts(productIds: productIds) 75 | #expect(secondProducts.count == 2) 76 | 77 | // Products should be identical 78 | #expect(Set(firstProducts.map { $0.id }) == Set(secondProducts.map { $0.id })) 79 | } 80 | 81 | @Test("Handles partial cache hits") 82 | func testPartialCacheHit() async throws { 83 | // First, cache some products 84 | let initialProducts = Set([TestProducts.monthlyBasic]) 85 | _ = try await productService.retrieveProducts(productIds: initialProducts) 86 | 87 | // Now request both cached and uncached products 88 | let mixedProducts = Set([ 89 | TestProducts.monthlyBasic, // cached 90 | TestProducts.annualBasic // not cached 91 | ]) 92 | 93 | let products = try await productService.retrieveProducts(productIds: mixedProducts) 94 | #expect(products.count == 2) 95 | #expect(products.contains { $0.id == TestProducts.monthlyBasic }) 96 | #expect(products.contains { $0.id == TestProducts.annualBasic }) 97 | } 98 | 99 | @Test("Reports product as not purchased initially") 100 | func testProductNotPurchased() async throws { 101 | let isPurchased = try await productService.isPurchased(TestProducts.removeAds) 102 | #expect(isPurchased == false) 103 | } 104 | 105 | @Test("Checks purchase status by Product object") 106 | func testPurchaseStatusByProduct() async throws { 107 | let products = try await productService.retrieveProducts(productIds: [TestProducts.premiumFeatures]) 108 | 109 | guard let product = products.first else { 110 | Issue.record("Failed to retrieve product") 111 | return 112 | } 113 | 114 | let isPurchased = try await productService.isPurchased(product) 115 | #expect(isPurchased == false) 116 | } 117 | 118 | @Test("Handles empty product ID set") 119 | func testEmptyProductSet() async throws { 120 | let productService = CachingProductService() 121 | let products = try await productService.retrieveProducts(productIds: []) 122 | #expect(products.isEmpty) 123 | } 124 | 125 | @Test("Handles concurrent requests for same products") 126 | func testConcurrentRequests() async throws { 127 | let productService = CachingProductService() 128 | let productIds = Set([TestProducts.monthlyBasic]) 129 | 130 | // Launch multiple concurrent requests 131 | async let products1 = productService.retrieveProducts(productIds: productIds) 132 | async let products2 = productService.retrieveProducts(productIds: productIds) 133 | async let products3 = productService.retrieveProducts(productIds: productIds) 134 | 135 | let results = try await [products1, products2, products3] 136 | 137 | // All should succeed and return the same product 138 | for result in results { 139 | #expect(result.count == 1) 140 | #expect(result.first?.id == TestProducts.monthlyBasic) 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /Tests/MercatoTests/CurrencySymbolsLibraryTests.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Foundation 24 | import Testing 25 | @testable import Mercato 26 | 27 | @Suite("CurrencySymbolsLibrary Tests") 28 | struct CurrencySymbolsLibraryTests { 29 | 30 | @Suite("Common Currency Symbols") 31 | struct CommonCurrencyTests { 32 | 33 | @Test("Returns correct symbol for USD") 34 | func testUSDSymbol() { 35 | let symbol = CurrencySymbolsLibrary.shared.symbol(for: "USD") 36 | #expect(symbol == "$") 37 | } 38 | 39 | @Test("Returns correct symbol for EUR") 40 | func testEURSymbol() { 41 | let symbol = CurrencySymbolsLibrary.shared.symbol(for: "EUR") 42 | #expect(symbol == "€") 43 | } 44 | 45 | @Test("Returns correct symbol for GBP") 46 | func testGBPSymbol() { 47 | let symbol = CurrencySymbolsLibrary.shared.symbol(for: "GBP") 48 | #expect(symbol == "£") 49 | } 50 | 51 | @Test("Returns correct symbol for JPY") 52 | func testJPYSymbol() { 53 | let symbol = CurrencySymbolsLibrary.shared.symbol(for: "JPY") 54 | #expect(symbol == "¥") 55 | } 56 | 57 | @Test("Returns correct symbol for CNY") 58 | func testCNYSymbol() { 59 | let symbol = CurrencySymbolsLibrary.shared.symbol(for: "CNY") 60 | #expect(symbol == "¥" || symbol == "¥" || symbol == "元") 61 | } 62 | 63 | @Test("Returns correct symbol for CAD") 64 | func testCADSymbol() { 65 | let symbol = CurrencySymbolsLibrary.shared.symbol(for: "CAD") 66 | #expect(symbol == "$" || symbol == "CA$") 67 | } 68 | 69 | @Test("Returns correct symbol for AUD") 70 | func testAUDSymbol() { 71 | let symbol = CurrencySymbolsLibrary.shared.symbol(for: "AUD") 72 | #expect(symbol == "$" || symbol == "A$") 73 | } 74 | 75 | @Test("Returns correct symbol for CHF") 76 | func testCHFSymbol() { 77 | let symbol = CurrencySymbolsLibrary.shared.symbol(for: "CHF") 78 | #expect(symbol == "CHF" || symbol == "Fr") 79 | } 80 | 81 | @Test("Returns correct symbol for INR") 82 | func testINRSymbol() { 83 | let symbol = CurrencySymbolsLibrary.shared.symbol(for: "INR") 84 | #expect(symbol == "₹") 85 | } 86 | 87 | @Test("Returns correct symbol for KRW") 88 | func testKRWSymbol() { 89 | let symbol = CurrencySymbolsLibrary.shared.symbol(for: "KRW") 90 | #expect(symbol == "₩") 91 | } 92 | 93 | @Test("Returns correct symbol for RUB") 94 | func testRUBSymbol() { 95 | let symbol = CurrencySymbolsLibrary.shared.symbol(for: "RUB") 96 | #expect(symbol == "₽") 97 | } 98 | 99 | @Test("Returns correct symbol for BRL") 100 | func testBRLSymbol() { 101 | let symbol = CurrencySymbolsLibrary.shared.symbol(for: "BRL") 102 | #expect(symbol == "R$") 103 | } 104 | 105 | @Test("Returns correct symbol for MXN") 106 | func testMXNSymbol() { 107 | let symbol = CurrencySymbolsLibrary.shared.symbol(for: "MXN") 108 | #expect(symbol == "$" || symbol == "MX$") 109 | } 110 | } 111 | 112 | @Suite("Edge Cases") 113 | struct EdgeCaseTests { 114 | 115 | @Test("Returns nil for unknown currency") 116 | func testUnknownCurrency() { 117 | let symbol = CurrencySymbolsLibrary.shared.symbol(for: "XaYZ") 118 | #expect(symbol == nil) 119 | } 120 | 121 | @Test("Handles lowercase currency codes") 122 | func testLowercaseCurrencyCode() { 123 | let symbol = CurrencySymbolsLibrary.shared.symbol(for: "usd") 124 | // Should either handle case-insensitively or return nil 125 | #expect(symbol == "$") 126 | } 127 | 128 | @Test("Handles empty string") 129 | func testEmptyString() { 130 | let symbol = CurrencySymbolsLibrary.shared.symbol(for: "") 131 | #expect(symbol == nil) 132 | } 133 | 134 | @Test("Handles special characters in currency code") 135 | func testSpecialCharacters() { 136 | let symbol = CurrencySymbolsLibrary.shared.symbol(for: "US$") 137 | #expect(symbol == nil) 138 | } 139 | } 140 | 141 | @Test("Thread safety of shared instance") 142 | func testThreadSafety() async { 143 | // Test concurrent access to the shared instance 144 | await withTaskGroup(of: String?.self) { group in 145 | for currency in ["USD", "EUR", "GBP", "JPY"] { 146 | group.addTask { 147 | CurrencySymbolsLibrary.shared.symbol(for: currency) 148 | } 149 | } 150 | 151 | var results: [String?] = [] 152 | for await result in group { 153 | results.append(result) 154 | } 155 | 156 | // All results should be valid 157 | #expect(results.count == 4) 158 | #expect(results.contains { $0 == "$" }) 159 | #expect(results.contains { $0 == "€" }) 160 | #expect(results.contains { $0 == "£" }) 161 | #expect(results.contains { $0 == "¥" }) 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # Mercato 6 | 7 | [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](http://mit-license.org) 8 | [![Platform](http://img.shields.io/badge/platform-iOS%20%7C%20macOS%20%7C%20tvOS%20%7C%20watchOS%20%7C%20visionOS-lightgrey.svg?style=flat)](https://developer.apple.com/resources/) 9 | [![Swift](https://img.shields.io/badge/swift-5.10-orange.svg)](https://developer.apple.com/swift) 10 | 11 | StoreKit 2 wrapper for In-App Purchases. 12 | 13 | ## Installation 14 | 15 | ### Swift Package Manager 16 | 17 | ```swift 18 | .package(url: "https://github.com/tikhop/Mercato.git", .upToNextMajor(from: "1.1.0")) 19 | ``` 20 | 21 | ## Requirements 22 | 23 | - Swift 5.10+ 24 | - iOS 15.4+ / macOS 12.3+ / tvOS 17.0+ / watchOS 10.0+ / visionOS 1.0+ 25 | 26 | ## Usage 27 | 28 | ### Basic Purchase 29 | 30 | ```swift 31 | import Mercato 32 | 33 | // Fetch products 34 | let products = try await Mercato.retrieveProducts( 35 | productIds: ["com.app.premium", "com.app.subscription"] 36 | ) 37 | 38 | // Purchase 39 | let purchase = try await Mercato.purchase( 40 | product: product, 41 | finishAutomatically: false 42 | ) 43 | 44 | // Deliver content and finish 45 | grantAccess(for: purchase.productId) 46 | await purchase.finish() 47 | ``` 48 | 49 | ### Transaction Monitoring 50 | 51 | ```swift 52 | // Current entitlements 53 | for await result in Mercato.currentEntitlements { 54 | let transaction = try result.payload 55 | // Active subscriptions and non-consumables 56 | } 57 | 58 | // Live updates 59 | for await result in Mercato.updates { 60 | let transaction = try result.payload 61 | await processTransaction(transaction) 62 | await transaction.finish() 63 | } 64 | ``` 65 | 66 | ### Subscription Features 67 | 68 | ```swift 69 | // Check eligibility 70 | if await product.isEligibleForIntroOffer { 71 | // Show intro pricing 72 | } 73 | 74 | // Product info 75 | product.localizedPrice // "$9.99" 76 | product.localizedPeriod // "1 month" 77 | product.hasTrial // true/false 78 | product.priceInDay // 0.33 79 | ``` 80 | 81 | ### Purchase Options 82 | 83 | ```swift 84 | let options = Mercato.PurchaseOptionsBuilder() 85 | .setQuantity(3) 86 | .setPromotionalOffer(offer) 87 | .setAppAccountToken(token) 88 | .build() 89 | 90 | let purchase = try await Mercato.purchase( 91 | product: product, 92 | options: options, 93 | finishAutomatically: false 94 | ) 95 | ``` 96 | 97 | 98 | ### Error Handling 99 | 100 | ```swift 101 | do { 102 | let purchase = try await Mercato.purchase(product: product) 103 | } catch MercatoError.canceledByUser { 104 | // User canceled 105 | } catch MercatoError.purchaseIsPending { 106 | // Ask to Buy 107 | } catch MercatoError.productUnavailable { 108 | // Product not found 109 | } 110 | ``` 111 | 112 | ### Utilities 113 | 114 | ```swift 115 | // Check purchase status 116 | let isPurchased = try await Mercato.isPurchased("com.app.premium") 117 | 118 | // Get latest transaction 119 | if let result = await Mercato.latest(for: productId) { 120 | let transaction = try result.payload 121 | } 122 | 123 | // Sync purchases (rarely needed) 124 | try await Mercato.syncPurchases() 125 | ``` 126 | 127 | ## Advanced Commerce (iOS 18.4+) 128 | 129 | Mercato includes support for Advanced Commerce API. 130 | > This feature requires iOS 18.4+, macOS 15.4+, tvOS 18.4+, watchOS 11.4+, or visionOS 2.4+. 131 | 132 | ### Purchase with Compact JWS 133 | 134 | ```swift 135 | import AdvancedCommerceMercato 136 | 137 | // iOS/macOS/tvOS/visionOS - requires UI context 138 | let purchase = try await AdvancedCommerceMercato.purchase( 139 | productId: "com.app.premium", 140 | compactJWS: signedJWS, 141 | confirmIn: viewController // UIViewController on iOS, NSWindow on macOS 142 | ) 143 | 144 | // watchOS - no UI context needed 145 | let purchase = try await AdvancedCommerceMercato.purchase( 146 | productId: "com.app.premium", 147 | compactJWS: signedJWS 148 | ) 149 | ``` 150 | 151 | ### Purchase with Advanced Commerce Data 152 | 153 | ```swift 154 | // Direct purchase with raw Advanced Commerce data 155 | let result = try await AdvancedCommerceMercato.purchase( 156 | productId: "com.app.premium", 157 | advancedCommerceData: dataFromServer 158 | ) 159 | ``` 160 | 161 | ### Request Validation 162 | 163 | All Advanced Commerce request models include built-in validation to ensure data integrity before sending to your server: 164 | 165 | ```swift 166 | import AdvancedCommerceMercato 167 | 168 | // Create a request 169 | let request = OneTimeChargeCreateRequest( 170 | currency: "USD", 171 | item: OneTimeChargeItem( 172 | sku: "BOOK_001", 173 | displayName: "Digital Book", 174 | description: "Premium content", 175 | price: 999 176 | ), 177 | requestInfo: RequestInfo(requestReferenceId: UUID().uuidString), 178 | taxCode: "C003-00-2" 179 | ) 180 | 181 | // Validate before sending to server 182 | do { 183 | try request.validate() 184 | // Request is valid, proceed with encoding and sending 185 | } catch { 186 | print("Validation error: \(error)") 187 | } 188 | ``` 189 | 190 | Validation checks include currency codes, tax codes, storefronts, transaction IDs, and required fields. 191 | 192 | ### Transaction Management 193 | 194 | ```swift 195 | // Get latest transaction for a product 196 | if let result = await AdvancedCommerceMercato.latestTransaction(for: productId) { 197 | let transaction = try result.payload 198 | } 199 | 200 | // Current entitlements 201 | if let entitlements = await AdvancedCommerceMercato.currentEntitlements(for: productId) { 202 | for await result in entitlements { 203 | // Process active subscriptions 204 | } 205 | } 206 | 207 | // All transactions 208 | if let transactions = await AdvancedCommerceMercato.allTransactions(for: productId) { 209 | for await result in transactions { 210 | // Process transaction history 211 | } 212 | } 213 | ``` 214 | 215 | For detailed information about implementing Advanced Commerce, including request signing and supported operations, see [AdvancedCommerce.md](Documentation/AdvancedCommerce.md). 216 | 217 | ## Documentation 218 | 219 | See [Usage.md](Documentation/Usage.md) for complete documentation. 220 | 221 | ## Contributing 222 | 223 | Contributions are welcome. Please feel free to submit a Pull Request. 224 | 225 | ## License 226 | 227 | MIT. See [LICENSE](LICENSE) for details. 228 | -------------------------------------------------------------------------------- /Tests/MercatoTests/TestHelper.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Foundation 24 | import StoreKit 25 | import StoreKitTest 26 | import Testing 27 | @testable import Mercato 28 | 29 | // MARK: - TestProducts 30 | 31 | enum TestProducts { 32 | // Consumables 33 | static let coins100 = "com.mercato.tests.consumable.coins.100" 34 | static let coins500 = "com.mercato.tests.consumable.coins.500" 35 | static let coins1000 = "com.mercato.tests.consumable.coins.1000" 36 | static let extraLife = "com.mercato.tests.consumable.life" 37 | 38 | // Non-Consumables 39 | static let removeAds = "com.mercato.tests.nonconsumable.removeads" 40 | static let premiumFeatures = "com.mercato.tests.nonconsumable.premium" 41 | static let lifetimeAccess = "com.mercato.tests.nonconsumable.lifetime" 42 | 43 | // Non-Renewing Subscriptions 44 | static let nonRenewingMonth = "com.mercato.tests.nonrenewing.month" 45 | static let nonRenewingYear = "com.mercato.tests.nonrenewing.year" 46 | 47 | // Basic Subscriptions 48 | static let weeklyBasic = "com.mercato.tests.subscription.weekly" 49 | static let monthlyBasic = "com.mercato.tests.subscription.monthly" 50 | static let annualBasic = "com.mercato.tests.subscription.annual" 51 | 52 | // Trial Subscriptions 53 | static let weeklyTrial = "com.mercato.tests.subscription.weekly.trial" 54 | static let monthlyTrial = "com.mercato.tests.subscription.monthly.trial" 55 | static let annualTrial = "com.mercato.tests.subscription.annual.trial" 56 | 57 | // Intro Offer Subscriptions 58 | static let monthlyIntro = "com.mercato.tests.subscription.monthly.intro" 59 | static let annualIntro = "com.mercato.tests.subscription.annual.intro" 60 | 61 | // Premium Subscriptions 62 | static let premiumMonthly = "com.mercato.tests.subscription.premium.monthly" 63 | static let premiumAnnual = "com.mercato.tests.subscription.premium.annual" 64 | } 65 | 66 | // MARK: - TestSession 67 | 68 | final class TestSession: @unchecked Sendable { 69 | let session: SKTestSession 70 | 71 | init() { 72 | let url = Bundle.module.url(forResource: "Mercato", withExtension: "storekit")! 73 | session = try! SKTestSession(contentsOf: url) 74 | session.resetToDefaultState() 75 | session.disableDialogs = true 76 | } 77 | 78 | func clearAllTransactions() { 79 | session.clearTransactions() 80 | } 81 | 82 | func expireSubscription(for productID: String) throws { 83 | try session.expireSubscription(productIdentifier: productID) 84 | } 85 | 86 | func forceRenewalOfSubscription(for productID: String) throws { 87 | try session.forceRenewalOfSubscription(productIdentifier: productID) 88 | } 89 | 90 | func refundTransaction(identifier: UInt) throws { 91 | try session.refundTransaction(identifier: identifier) 92 | } 93 | 94 | func setAskToBuyEnabled(_ enabled: Bool) { 95 | session.askToBuyEnabled = enabled 96 | } 97 | 98 | @available(iOS 17.0, *) 99 | func buy(productID: String) async throws { 100 | try await session.buyProduct(identifier: TestProducts.monthlyBasic) 101 | } 102 | 103 | public static let shared = TestSession() 104 | } 105 | 106 | // MARK: - MercatoAssertions 107 | 108 | enum MercatoAssertions { 109 | 110 | static func assertProductAvailable(_ productID: String, in products: [Product]) { 111 | #expect(products.contains { $0.id == productID }, "Product \(productID) should be available") 112 | } 113 | 114 | static func assertSubscriptionHasTrial(_ product: Product) { 115 | #expect(product.hasTrial == true, "Product \(product.id) should have a trial") 116 | } 117 | 118 | static func assertSubscriptionHasIntroOffer(_ product: Product) { 119 | #expect(product.hasIntroductoryOffer == true, "Product \(product.id) should have an intro offer") 120 | } 121 | 122 | static func assertPriceEquals(_ product: Product, expectedPrice: Decimal) { 123 | #expect(product.price == expectedPrice, "Product \(product.id) price should be \(expectedPrice)") 124 | } 125 | 126 | static func assertPurchaseSucceeded(_ purchase: Purchase) { 127 | #expect(purchase.productId == purchase.product.id, "Purchase product ID should match") 128 | #expect(purchase.quantity > 0, "Purchase quantity should be greater than 0") 129 | } 130 | } 131 | 132 | // MARK: - Test Utilities 133 | 134 | extension Decimal { 135 | /// Common test prices 136 | static let testPrice099 = Decimal(0.99) 137 | static let testPrice199 = Decimal(1.99) 138 | static let testPrice299 = Decimal(2.99) 139 | static let testPrice499 = Decimal(4.99) 140 | static let testPrice999 = Decimal(9.99) 141 | static let testPrice1999 = Decimal(19.99) 142 | static let testPrice2999 = Decimal(29.99) 143 | static let testPrice4999 = Decimal(49.99) 144 | static let testPrice5999 = Decimal(59.99) 145 | static let testPrice9999 = Decimal(99.99) 146 | } 147 | 148 | // MARK: - Locale Testing 149 | 150 | extension Locale { 151 | static let testUS = Locale(identifier: "en_US") 152 | static let testUK = Locale(identifier: "en_GB") 153 | static let testFR = Locale(identifier: "fr_FR") 154 | static let testDE = Locale(identifier: "de_DE") 155 | static let testJP = Locale(identifier: "ja_JP") 156 | static let testRU = Locale(identifier: "ru_RU") 157 | } 158 | 159 | // MARK: - TestCurrencies 160 | 161 | enum TestCurrencies { 162 | static let usd = "USD" 163 | static let eur = "EUR" 164 | static let gbp = "GBP" 165 | static let jpy = "JPY" 166 | static let cad = "CAD" 167 | static let aud = "AUD" 168 | static let rub = "RUB" 169 | } 170 | -------------------------------------------------------------------------------- /Tests/MercatoTests/PeriodFormatterTests.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Foundation 24 | import Testing 25 | @testable import Mercato 26 | 27 | @Suite("PeriodFormatter Tests") 28 | enum PeriodFormatterTests { 29 | 30 | @Suite("Format Period") 31 | struct FormatPeriodTests { 32 | 33 | @Test("Formats single day") 34 | func testSingleDay() { 35 | let formatted = PeriodFormatter.format(unit: .day, numberOfUnits: 1) 36 | #expect(formatted == "1 day") 37 | } 38 | 39 | @Test("Formats multiple days") 40 | func testMultipleDays() { 41 | let formatted = PeriodFormatter.format(unit: .day, numberOfUnits: 3) 42 | #expect(formatted == "3 days") 43 | } 44 | 45 | @Test("Formats single week") 46 | func testSingleWeek() { 47 | let formatted = PeriodFormatter.format(unit: .weekOfMonth, numberOfUnits: 1) 48 | #expect(formatted == "1 week") 49 | } 50 | 51 | @Test("Formats multiple weeks") 52 | func testMultipleWeeks() { 53 | let formatted = PeriodFormatter.format(unit: .weekOfMonth, numberOfUnits: 2) 54 | #expect(formatted == "2 weeks") 55 | } 56 | 57 | @Test("Formats single month") 58 | func testSingleMonth() { 59 | let formatted = PeriodFormatter.format(unit: .month, numberOfUnits: 1) 60 | #expect(formatted == "1 month") 61 | } 62 | 63 | @Test("Formats multiple months") 64 | func testMultipleMonths() { 65 | let formatted = PeriodFormatter.format(unit: .month, numberOfUnits: 6) 66 | #expect(formatted == "6 months") 67 | } 68 | 69 | @Test("Formats single year") 70 | func testSingleYear() { 71 | let formatted = PeriodFormatter.format(unit: .year, numberOfUnits: 1) 72 | #expect(formatted == "1 year") 73 | } 74 | 75 | @Test("Formats multiple years") 76 | func testMultipleYears() { 77 | let formatted = PeriodFormatter.format(unit: .year, numberOfUnits: 2) 78 | #expect(formatted == "2 years") 79 | } 80 | } 81 | 82 | @Suite("Special Cases") 83 | struct SpecialCasesTests { 84 | 85 | @Test("Handles zero units") 86 | func testZeroUnits() { 87 | let formatted = PeriodFormatter.format(unit: .day, numberOfUnits: 0) 88 | // Depending on implementation, this might return nil or "0 days" 89 | #expect(formatted == nil || formatted == "0 days") 90 | } 91 | 92 | @Test("Handles common subscription periods") 93 | func testCommonSubscriptionPeriods() { 94 | // Weekly 95 | let weekly = PeriodFormatter.format(unit: .weekOfMonth, numberOfUnits: 1) 96 | #expect(weekly == "1 week") 97 | 98 | // Monthly 99 | let monthly = PeriodFormatter.format(unit: .month, numberOfUnits: 1) 100 | #expect(monthly == "1 month") 101 | 102 | // Quarterly 103 | let quarterly = PeriodFormatter.format(unit: .month, numberOfUnits: 3) 104 | #expect(quarterly == "3 months") 105 | 106 | // Semi-annual 107 | let semiAnnual = PeriodFormatter.format(unit: .month, numberOfUnits: 6) 108 | #expect(semiAnnual == "6 months") 109 | 110 | // Annual 111 | let annual = PeriodFormatter.format(unit: .year, numberOfUnits: 1) 112 | #expect(annual == "1 year") 113 | } 114 | 115 | @Test("Handles trial periods") 116 | func testTrialPeriods() { 117 | // 3-day trial 118 | let threeDayTrial = PeriodFormatter.format(unit: .day, numberOfUnits: 3) 119 | #expect(threeDayTrial == "3 days") 120 | 121 | // 7-day trial 122 | let sevenDayTrial = PeriodFormatter.format(unit: .day, numberOfUnits: 7) 123 | #expect(sevenDayTrial == "7 days") 124 | 125 | // 14-day trial 126 | let fourteenDayTrial = PeriodFormatter.format(unit: .day, numberOfUnits: 14) 127 | #expect(fourteenDayTrial == "14 days") 128 | 129 | // 30-day trial 130 | let thirtyDayTrial = PeriodFormatter.format(unit: .day, numberOfUnits: 30) 131 | #expect(thirtyDayTrial == "30 days") 132 | } 133 | } 134 | 135 | @Suite("Localization") 136 | struct LocalizationTests { 137 | 138 | @Test("Returns English format by default") 139 | func testEnglishFormatDefault() { 140 | // The formatter should return English by default 141 | let formatted = PeriodFormatter.format(unit: .month, numberOfUnits: 1) 142 | #expect(formatted == "1 month") 143 | } 144 | 145 | @Test("Handles plural forms correctly") 146 | func testPluralForms() { 147 | // Singular forms 148 | #expect(PeriodFormatter.format(unit: .day, numberOfUnits: 1) == "1 day") 149 | #expect(PeriodFormatter.format(unit: .weekOfMonth, numberOfUnits: 1) == "1 week") 150 | #expect(PeriodFormatter.format(unit: .month, numberOfUnits: 1) == "1 month") 151 | #expect(PeriodFormatter.format(unit: .year, numberOfUnits: 1) == "1 year") 152 | 153 | // Plural forms 154 | #expect(PeriodFormatter.format(unit: .day, numberOfUnits: 2)?.contains("days") == true) 155 | #expect(PeriodFormatter.format(unit: .weekOfMonth, numberOfUnits: 2)?.contains("weeks") == true) 156 | #expect(PeriodFormatter.format(unit: .month, numberOfUnits: 2)?.contains("months") == true) 157 | #expect(PeriodFormatter.format(unit: .year, numberOfUnits: 2)?.contains("years") == true) 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /Sources/Mercato/ProductService.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import StoreKit 24 | 25 | // MARK: - ProductService 26 | 27 | public protocol ProductService: Sendable { 28 | associatedtype ProductItem 29 | /// Requests product data from the App Store. 30 | /// - Parameter identifiers: A set of product identifiers to load from the App Store. If any 31 | /// identifiers are not found, they will be excluded from the return 32 | /// value. 33 | /// - Returns: An array of all the products received from the App Store. 34 | /// - Throws: `MercatoError` 35 | /// 36 | func retrieveProducts(productIds: Set) async throws(MercatoError) -> [ProductItem] 37 | 38 | } 39 | 40 | 41 | // MARK: - StoreKitProductService 42 | 43 | public protocol StoreKitProductService: ProductService where ProductItem == StoreKit.Product { 44 | /// Checks if a given product has been purchased. 45 | /// 46 | /// - Parameter product: The `Product` to check. 47 | /// - Returns: A Boolean value indicating whether the product has been purchased. 48 | /// - Throws: `MercatoError` if the purchase status could not be determined. 49 | func isPurchased(_ product: StoreKit.Product) async throws(MercatoError) -> Bool 50 | 51 | /// Checks if a product with the given identifier has been purchased. 52 | /// 53 | /// - Parameter productIdentifier: The identifier of the product to check. 54 | /// - Returns: A Boolean value indicating whether the product has been purchased. 55 | /// - Throws: `MercatoError` if the purchase status could not be determined. 56 | func isPurchased(_ productIdentifier: String) async throws(MercatoError) -> Bool 57 | } 58 | 59 | 60 | // MARK: - FetchableProduct 61 | 62 | public protocol FetchableProduct { 63 | var id: String { get } 64 | 65 | static func products(for identifiers: some Collection) async throws -> [Self] 66 | } 67 | 68 | // MARK: - AbstractCachingProductService 69 | 70 | public class AbstractCachingProductService: ProductService, @unchecked Sendable { 71 | public typealias ProductItem = P 72 | 73 | internal let lock = DefaultLock() 74 | internal var cachedProducts: [String: ProductItem] = [:] 75 | internal var activeFetches: [Set: Task<[ProductItem], Error>] = [:] 76 | 77 | public init() { } 78 | 79 | public func retrieveProducts(productIds: Set) async throws(MercatoError) -> [ProductItem] { 80 | lock.lock() 81 | let cachedProducts = cachedProducts 82 | lock.unlock() 83 | 84 | var cached: [ProductItem] = [] 85 | var missingIds = Set() 86 | 87 | for id in productIds { 88 | if let product = cachedProducts[id] { 89 | cached.append(product) 90 | } else { 91 | missingIds.insert(id) 92 | } 93 | } 94 | 95 | if missingIds.isEmpty { 96 | return cached 97 | } 98 | 99 | return try await fetchProducts(productIds: missingIds) + cached 100 | } 101 | 102 | internal func fetchProducts(productIds: Set) async throws(MercatoError) -> [ProductItem] { 103 | lock.lock() 104 | if let existingTask = activeFetches[productIds] { 105 | lock.unlock() 106 | do { 107 | let fetchedProducts = try await existingTask.value 108 | return fetchedProducts 109 | } catch { 110 | throw MercatoError.wrapped(error: error) 111 | } 112 | } 113 | 114 | let fetchTask = Task<[ProductItem], Error> { 115 | try await ProductItem.products(for: productIds) 116 | } 117 | activeFetches[productIds] = fetchTask 118 | lock.unlock() 119 | 120 | do { 121 | let fetchedProducts = try await fetchTask.value 122 | 123 | lock.lock() 124 | for product in fetchedProducts { 125 | cachedProducts[product.id] = product 126 | } 127 | activeFetches.removeValue(forKey: productIds) 128 | lock.unlock() 129 | 130 | return fetchedProducts 131 | } catch { 132 | lock.lock() 133 | activeFetches.removeValue(forKey: productIds) 134 | lock.unlock() 135 | 136 | throw MercatoError.wrapped(error: error) 137 | } 138 | } 139 | } 140 | 141 | extension AbstractCachingProductService where ProductItem == StoreKit.Product { 142 | /// Checks if a given product has been purchased. 143 | /// 144 | /// - Parameter product: The `Product` to check. 145 | /// - Returns: A Boolean value indicating whether the product has been purchased. 146 | /// - Throws: `MercatoError` if the purchase status could not be determined. 147 | public nonisolated func isPurchased(_ product: Product) async throws(MercatoError) -> Bool { 148 | try await isPurchased(product.id) 149 | } 150 | 151 | /// Checks if a product with the given identifier has been purchased. 152 | /// 153 | /// - Parameter productIdentifier: The identifier of the product to check. 154 | /// - Returns: A Boolean value indicating whether the product has been purchased. 155 | /// - Throws: `MercatoError` if the purchase status could not be determined. 156 | public nonisolated func isPurchased(_ productIdentifier: String) async throws(MercatoError) -> Bool { 157 | guard let result = await Transaction.latest(for: productIdentifier) else { 158 | return false 159 | } 160 | 161 | do { 162 | let tx = try result.payloadValue 163 | return tx.revocationDate == nil && !tx.isUpgraded 164 | } catch { 165 | throw MercatoError.wrapped(error: error) 166 | } 167 | } 168 | } 169 | 170 | public typealias CachingProductService = AbstractCachingProductService 171 | extension CachingProductService: StoreKitProductService { } 172 | extension StoreKit.Product: FetchableProduct { } 173 | -------------------------------------------------------------------------------- /Sources/AdvancedCommerceMercato/Models/ValidationUtils.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Foundation 24 | 25 | // MARK: - Validatable 26 | 27 | public protocol Validatable { 28 | func validate() throws 29 | } 30 | 31 | // MARK: - ValidationError 32 | 33 | public enum ValidationError: Error { 34 | case invalidLength(String) 35 | case invalidValue(String) 36 | } 37 | 38 | // MARK: LocalizedError 39 | 40 | extension ValidationError: LocalizedError { 41 | public var errorDescription: String? { 42 | switch self { 43 | case .invalidLength(let msg), 44 | .invalidValue(let msg): 45 | msg 46 | } 47 | } 48 | } 49 | 50 | // MARK: - ValidationUtils 51 | 52 | public enum ValidationUtils { 53 | enum Constants { 54 | static let kCurrencyCodeLength = 3 55 | static let kMaximumStorefrontLength = 10 56 | static let kMaximumRequestReferenceIdLength = 36 57 | static let kMaximumDescriptionLength = 45 58 | static let kMaximumDisplayNameLength = 30 59 | static let kMaximumSkuLength = 128 60 | static let kISOCurrencyRegex = "^[A-Z]{3}$" 61 | } 62 | 63 | /// Validates currency code according to ISO 4217 standard. 64 | /// - Parameter currency: The currency code to validate 65 | /// - Throws: ValidationError if validation fails 66 | public static func validateCurrency(_ currency: String) throws { 67 | guard currency.count == Constants.kCurrencyCodeLength else { 68 | throw ValidationError.invalidLength("Currency must be a 3-letter ISO 4217 code") 69 | } 70 | guard currency.range(of: Constants.kISOCurrencyRegex, options: .regularExpression) != nil else { 71 | throw ValidationError.invalidValue("Currency must contain only uppercase letters") 72 | } 73 | } 74 | 75 | /// Validates tax code is not empty. 76 | /// - Parameter taxCode: The tax code to validate 77 | /// - Throws: ValidationError if validation fails 78 | public static func validateTaxCode(_ taxCode: String) throws { 79 | guard !taxCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { 80 | throw ValidationError.invalidValue("Tax code cannot be empty") 81 | } 82 | } 83 | 84 | /// Validates transactionId is not empty. 85 | /// - Parameter transactionId: The transaction ID to validate 86 | /// - Throws: ValidationError if validation fails 87 | public static func validateTransactionId(_ transactionId: String) throws { 88 | guard !transactionId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { 89 | throw ValidationError.invalidValue("Transaction ID cannot be empty") 90 | } 91 | } 92 | 93 | /// Validates target product ID is not empty. 94 | /// - Parameter targetProductId: The target product ID to validate 95 | /// - Throws: ValidationError if validation fails 96 | public static func validateTargetProductId(_ targetProductId: String) throws { 97 | guard !targetProductId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { 98 | throw ValidationError.invalidValue("Target Product ID cannot be empty") 99 | } 100 | } 101 | 102 | /// Validates UUID is not null and its string representation doesn't exceed maximum length. 103 | /// - Parameter uuid: The UUID to validate 104 | /// - Throws: ValidationError if validation fails 105 | public static func validUUID(_ uuid: UUID) throws { 106 | let uuidString = uuid.uuidString 107 | guard uuidString.count <= Constants.kMaximumRequestReferenceIdLength else { 108 | throw ValidationError.invalidLength("UUID string representation cannot exceed \(Constants.kMaximumRequestReferenceIdLength) characters") 109 | } 110 | } 111 | 112 | /// Validates price is not null and non-negative. 113 | /// - Parameter price: The price to validate 114 | /// - Throws: ValidationError if validation fails 115 | public static func validatePrice(_ price: Int64) throws { 116 | guard price >= 0 else { 117 | throw ValidationError.invalidValue("Price cannot be negative") 118 | } 119 | } 120 | 121 | /// Validates description does not exceed maximum length. 122 | /// For required fields, caller should ensure description is not null before calling this method. 123 | /// - Parameter description: The description to validate 124 | /// - Throws: ValidationError if validation fails 125 | public static func validateDescription(_ description: String) throws { 126 | guard description.count <= Constants.kMaximumDescriptionLength else { 127 | throw ValidationError.invalidLength("Description length longer than maximum allowed") 128 | } 129 | } 130 | 131 | /// Validates display name does not exceed maximum length. 132 | /// For required fields, caller should ensure displayName is not null before calling this method. 133 | /// - Parameter displayName: The display name to validate 134 | /// - Throws: ValidationError if validation fails 135 | public static func validateDisplayName(_ displayName: String) throws { 136 | guard displayName.count <= Constants.kMaximumDisplayNameLength else { 137 | throw ValidationError.invalidLength("DisplayName length longer than maximum allowed") 138 | } 139 | } 140 | 141 | /// Validates SKU does not exceed maximum length. 142 | /// - Parameter sku: The SKU to validate 143 | /// - Throws: ValidationError if validation fails 144 | public static func validateSku(_ sku: String) throws { 145 | guard sku.count <= Constants.kMaximumSkuLength else { 146 | throw ValidationError.invalidLength("SKU length longer than maximum allowed") 147 | } 148 | } 149 | 150 | /// Validates SKU does not exceed maximum length. 151 | /// - Parameter sku: The SKU to validate 152 | /// - Throws: ValidationError if validation fails 153 | public static func validateStorefront(_ storefront: String) throws { 154 | guard storefront.count <= Constants.kMaximumStorefrontLength else { 155 | throw ValidationError.invalidLength("Storefront length longer than maximum allowed") 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /Sources/AdvancedCommerceMercato/Mercato+AdvancedCommerce.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // Mercato 4 | // 5 | // Created by PT on 8/26/25. 6 | // 7 | 8 | import Mercato 9 | import StoreKit 10 | 11 | #if canImport(AppKit) 12 | import AppKit 13 | 14 | public typealias PurchaseUIContext = NSWindow 15 | #elseif os(iOS) 16 | import UIKit 17 | 18 | public typealias PurchaseUIContext = UIViewController 19 | #endif 20 | 21 | // MARK: - AdvancedCommerceMercato 22 | 23 | @available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) 24 | public final class AdvancedCommerceMercato: Sendable { 25 | private let acProductService: any AdvancedCommerceProductService 26 | private let skProductService: any StoreKitProductService 27 | 28 | package convenience init() { 29 | self.init( 30 | acProductService: AdvancedCommerceCachingProductService(), 31 | skProductService: CachingProductService() 32 | ) 33 | } 34 | 35 | public init( 36 | acProductService: any AdvancedCommerceProductService, 37 | skProductService: any StoreKitProductService 38 | ) { 39 | self.acProductService = acProductService 40 | self.skProductService = skProductService 41 | } 42 | 43 | public func retrieveProducts(productIds: Set) async throws(MercatoError) -> [AdvancedCommerceProduct] { 44 | try await acProductService.retrieveProducts(productIds: productIds) 45 | } 46 | 47 | public func allTransactions(for productId: AdvancedCommerceProduct.ID) async -> Transaction.Transactions? { 48 | await acProductService.allTransactions(for: productId) 49 | } 50 | 51 | public func currentEntitlements(for productId: AdvancedCommerceProduct.ID) async -> Transaction.Transactions? { 52 | await acProductService.currentEntitlements(for: productId) 53 | } 54 | 55 | public func latestTransaction(for productId: AdvancedCommerceProduct.ID) async -> VerificationResult? { 56 | await acProductService.latestTransaction(for: productId) 57 | } 58 | 59 | public static let shared = AdvancedCommerceMercato() 60 | } 61 | 62 | 63 | @available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) 64 | @MainActor 65 | extension AdvancedCommerceMercato { 66 | public func purchase(productId: String, advancedCommerceData: Data) async throws -> Product.PurchaseResult { 67 | guard let product = try await skProductService.retrieveProducts(productIds: [productId]).first else { 68 | throw MercatoError.productNotFound(productId) 69 | } 70 | 71 | let options = Product.PurchaseOption.advancedCommerceData(advancedCommerceData) 72 | return try await product.purchase(options: [options]) 73 | } 74 | 75 | #if !os(watchOS) 76 | @available(iOS 18.4, macOS 15.4, tvOS 18.4, visionOS 2.4, *) 77 | @available(watchOS, unavailable) 78 | public func purchase( 79 | productId: String, 80 | compactJWS: String, 81 | confirmIn view: PurchaseUIContext, 82 | options: Set = [] 83 | ) async throws -> AdvancedCommercePurchase { 84 | guard let product = try await acProductService.retrieveProducts(productIds: [productId]).first else { 85 | throw MercatoError.productNotFound(productId) 86 | } 87 | 88 | do { 89 | let result = try await product.purchase(compactJWS: compactJWS, confirmIn: view, options: options) 90 | 91 | return try await handlePurchaseResult( 92 | result, 93 | product: product, 94 | finishAutomatically: false 95 | ) 96 | } catch { 97 | throw MercatoError.wrapped(error: error) 98 | } 99 | } 100 | #endif 101 | 102 | #if os(watchOS) 103 | @available(watchOS 11.4, *) 104 | @available(iOS, unavailable) 105 | @available(macOS, unavailable) 106 | @available(tvOS, unavailable) 107 | @available(visionOS, unavailable) 108 | public func purchase( 109 | productId: String, 110 | compactJWS: String, 111 | options: Set = [] 112 | ) async throws -> AdvancedCommercePurchase { 113 | guard let product = try await productService.retrieveProducts(productIds: [productId]).first else { 114 | throw MercatoError.productNotFound(productId) 115 | } 116 | 117 | do { 118 | let result = try await product.purchase(compactJWS: compactJWS, options: options) 119 | 120 | return try await handlePurchaseResult( 121 | result, 122 | product: product, 123 | finishAutomatically: false 124 | ) 125 | } catch { 126 | throw MercatoError.wrapped(error: error) 127 | } 128 | } 129 | #endif 130 | 131 | private func handlePurchaseResult( 132 | _ result: Product.PurchaseResult, 133 | product: AdvancedCommerceProduct, 134 | finishAutomatically: Bool 135 | ) async throws(MercatoError) -> AdvancedCommercePurchase { 136 | switch result { 137 | case .success(let verification): 138 | let transaction = try verification.payload 139 | 140 | if finishAutomatically { 141 | await transaction.finish() 142 | } 143 | 144 | return AdvancedCommercePurchase( 145 | product: product, 146 | result: verification, 147 | needsFinishTransaction: !finishAutomatically 148 | ) 149 | case .userCancelled: 150 | throw MercatoError.canceledByUser 151 | case .pending: 152 | throw MercatoError.purchaseIsPending 153 | @unknown default: 154 | throw MercatoError.unknown(error: nil) 155 | } 156 | } 157 | } 158 | 159 | @available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) 160 | extension AdvancedCommerceMercato { 161 | public static func purchase(productId: String, advancedCommerceData: Data) async throws -> Product.PurchaseResult { 162 | try await shared.purchase(productId: productId, advancedCommerceData: advancedCommerceData) 163 | } 164 | 165 | #if !os(watchOS) 166 | @available(iOS 18.4, macOS 15.4, tvOS 18.4, visionOS 2.4, *) 167 | @available(watchOS, unavailable) 168 | public static func purchase( 169 | productId: String, 170 | compactJWS: String, 171 | confirmIn view: PurchaseUIContext, 172 | options: Set = [] 173 | ) async throws -> AdvancedCommercePurchase { 174 | try await shared.purchase(productId: productId, compactJWS: compactJWS, confirmIn: view, options: options) 175 | } 176 | #endif 177 | 178 | #if os(watchOS) 179 | @available(watchOS 11.4, *) 180 | @available(iOS, unavailable) 181 | @available(macOS, unavailable) 182 | @available(tvOS, unavailable) 183 | @available(visionOS, unavailable) 184 | public static func purchase( 185 | productId: String, 186 | compactJWS: String, 187 | options: Set = [] 188 | ) async throws -> AdvancedCommercePurchase { 189 | try await shared.purchase(productId: productId, compactJWS: compactJWS, options: options) 190 | } 191 | 192 | #endif 193 | 194 | public static func allTransactions(for productId: AdvancedCommerceProduct.ID) async -> Transaction.Transactions? { 195 | await shared.allTransactions(for: productId) 196 | } 197 | 198 | public static func currentEntitlements(for productId: AdvancedCommerceProduct.ID) async -> Transaction.Transactions? { 199 | await shared.currentEntitlements(for: productId) 200 | } 201 | 202 | public static func latestTransaction(for productId: AdvancedCommerceProduct.ID) async -> VerificationResult? { 203 | await shared.latestTransaction(for: productId) 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /Sources/AdvancedCommerceMercato/Models/SubscriptionModifyInAppRequest.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Foundation 24 | 25 | // MARK: - SubscriptionModifyInAppRequest 26 | 27 | /// The request data your app provides to make changes to an auto-renewable subscription. 28 | /// 29 | /// [SubscriptionModifyInAppRequest](https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmodifyinapprequest) 30 | public struct SubscriptionModifyInAppRequest: Decodable, Encodable { 31 | 32 | /// The operation type for this request. 33 | public var operation: String = RequestOperation.modifySubscription.rawValue 34 | 35 | /// The version of this request. 36 | public var version: String = RequestVersion.v1.rawValue 37 | 38 | /// The metadata to include in server requests. 39 | /// 40 | /// [requestInfo](https://developer.apple.com/documentation/advancedcommerceapi/requestinfo) 41 | public var requestInfo: RequestInfo 42 | 43 | /// The data your app provides to add items when it makes changes to an auto-renewable subscription. 44 | /// 45 | /// [SubscriptionModifyAddItem](https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmodifyadditem) 46 | public var addItems: [SubscriptionModifyAddItem]? 47 | 48 | /// The data your app provides to change an item of an auto-renewable subscription. 49 | /// 50 | /// [SubscriptionModifyChangeItem](https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmodifychangeitem) 51 | public var changeItems: [SubscriptionModifyChangeItem]? 52 | 53 | /// The data your app provides to remove items from an auto-renewable subscription. 54 | /// 55 | /// [SubscriptionModifyRemoveItem](https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmodifyremoveitem) 56 | public var removeItems: [SubscriptionModifyRemoveItem]? 57 | 58 | /// The currency of the price of the product. 59 | /// 60 | /// [currency](https://developer.apple.com/documentation/advancedcommerceapi/currency) 61 | public var currency: String? 62 | 63 | /// The data your app provides to change the description and display name of an auto-renewable subscription. 64 | /// 65 | /// [SubscriptionModifyDescriptors](https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmodifydescriptors) 66 | public var descriptors: SubscriptionModifyDescriptors? 67 | 68 | /// The data your app provides to change the period of an auto-renewable subscription. 69 | /// 70 | /// [SubscriptionModifyPeriodChange](https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmodifyperiodchange) 71 | public var periodChange: SubscriptionModifyPeriodChange? 72 | 73 | /// A Boolean value that determines whether to keep the existing billing cycle with the change you request. 74 | /// 75 | /// [retainBillingCycle](https://developer.apple.com/documentation/advancedcommerceapi/retainbillingcycle) 76 | public var retainBillingCycle: Bool 77 | 78 | /// The storefront for the transaction. 79 | /// 80 | /// [storefront](https://developer.apple.com/documentation/advancedcommerceapi/onetimechargecreaterequest) 81 | public var storefront: String? 82 | 83 | /// The tax code for this product. 84 | /// 85 | /// [taxCode](https://developer.apple.com/documentation/advancedcommerceapi/taxcode) 86 | public var taxCode: String? 87 | 88 | /// A unique identifier that the App Store generates for a transaction. 89 | /// 90 | /// [transactionId](https://developer.apple.com/documentation/advancedcommerceapi/transactionid) 91 | public var transactionId: String 92 | 93 | init( 94 | requestInfo: RequestInfo, 95 | addItems: [SubscriptionModifyAddItem]? = nil, 96 | changeItems: [SubscriptionModifyChangeItem]? = nil, 97 | removeItems: [SubscriptionModifyRemoveItem]? = nil, 98 | currency: String? = nil, 99 | descriptors: SubscriptionModifyDescriptors? = nil, 100 | periodChange: SubscriptionModifyPeriodChange? = nil, 101 | retainBillingCycle: Bool, 102 | storefront: String? = nil, 103 | taxCode: String? = nil, 104 | transactionId: String 105 | ) { 106 | self.requestInfo = requestInfo 107 | self.addItems = addItems 108 | self.changeItems = changeItems 109 | self.removeItems = removeItems 110 | self.currency = currency 111 | self.descriptors = descriptors 112 | self.periodChange = periodChange 113 | self.retainBillingCycle = retainBillingCycle 114 | self.storefront = storefront 115 | self.taxCode = taxCode 116 | self.transactionId = transactionId 117 | } 118 | 119 | public enum CodingKeys: String, CodingKey { 120 | case operation 121 | case version 122 | case requestInfo 123 | case addItems 124 | case changeItems 125 | case currency 126 | case descriptors 127 | case periodChange 128 | case removeItems 129 | case retainBillingCycle 130 | case storefront 131 | case taxCode 132 | case transactionId 133 | } 134 | } 135 | 136 | // MARK: - SubscriptionModifyInAppRequest Builder 137 | extension SubscriptionModifyInAppRequest { 138 | public func addItems(_ addItems: [SubscriptionModifyAddItem]?) -> SubscriptionModifyInAppRequest { 139 | var updated = self 140 | updated.addItems = addItems 141 | return updated 142 | } 143 | 144 | public func addAddItem(_ addItem: SubscriptionModifyAddItem) -> SubscriptionModifyInAppRequest { 145 | var updated = self 146 | if updated.addItems == nil { 147 | updated.addItems = [] 148 | } 149 | updated.addItems?.append(addItem) 150 | return updated 151 | } 152 | 153 | public func changeItems(_ changeItems: [SubscriptionModifyChangeItem]?) -> SubscriptionModifyInAppRequest { 154 | var updated = self 155 | updated.changeItems = changeItems 156 | return updated 157 | } 158 | 159 | public func addChangeItem(_ changeItem: SubscriptionModifyChangeItem) -> SubscriptionModifyInAppRequest { 160 | var updated = self 161 | if updated.changeItems == nil { 162 | updated.changeItems = [] 163 | } 164 | updated.changeItems?.append(changeItem) 165 | return updated 166 | } 167 | 168 | public func currency(_ currency: String?) -> SubscriptionModifyInAppRequest { 169 | var updated = self 170 | updated.currency = currency 171 | return updated 172 | } 173 | 174 | public func descriptors(_ descriptors: SubscriptionModifyDescriptors?) -> SubscriptionModifyInAppRequest { 175 | var updated = self 176 | updated.descriptors = descriptors 177 | return updated 178 | } 179 | 180 | public func periodChange(_ periodChange: SubscriptionModifyPeriodChange?) -> SubscriptionModifyInAppRequest { 181 | var updated = self 182 | updated.periodChange = periodChange 183 | return updated 184 | } 185 | 186 | public func removeItems(_ removeItems: [SubscriptionModifyRemoveItem]?) -> SubscriptionModifyInAppRequest { 187 | var updated = self 188 | updated.removeItems = removeItems 189 | return updated 190 | } 191 | 192 | public func addRemoveItem(_ removeItem: SubscriptionModifyRemoveItem) -> SubscriptionModifyInAppRequest { 193 | var updated = self 194 | if updated.removeItems == nil { 195 | updated.removeItems = [] 196 | } 197 | updated.removeItems?.append(removeItem) 198 | return updated 199 | } 200 | 201 | public func storefront(_ storefront: String?) -> SubscriptionModifyInAppRequest { 202 | var updated = self 203 | updated.storefront = storefront 204 | return updated 205 | } 206 | 207 | public func taxCode(_ taxCode: String?) -> SubscriptionModifyInAppRequest { 208 | var updated = self 209 | updated.taxCode = taxCode 210 | return updated 211 | } 212 | } 213 | 214 | extension SubscriptionModifyInAppRequest: Validatable { 215 | public func validate() throws { 216 | try requestInfo.validate() 217 | 218 | if let addItems { try addItems.forEach { try $0.validate() } } 219 | if let changeItems { try changeItems.forEach { try $0.validate() } } 220 | if let removeItems { try removeItems.forEach { try $0.validate() } } 221 | if let descriptors { try descriptors.validate() } 222 | if let currency { try ValidationUtils.validateCurrency(currency) } 223 | if let taxCode { try ValidationUtils.validateTaxCode(taxCode) } 224 | if let storefront { try ValidationUtils.validateStorefront(storefront) } 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /Sources/Mercato/Mercato+StoreKit.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Foundation 24 | import StoreKit 25 | 26 | typealias RenewalState = Product.SubscriptionInfo.RenewalState 27 | 28 | extension Product { 29 | /// Indicates whether the product is eligible for an introductory offer. 30 | public var isEligibleForIntroOffer: Bool { 31 | get async { 32 | await subscription?.isEligibleForIntroOffer ?? false 33 | } 34 | } 35 | 36 | /// Indicates whether the product has an active subscription. 37 | public var hasActiveSubscription: Bool { 38 | get async { 39 | await (try? subscription?.status.first?.state == RenewalState.subscribed) ?? false 40 | } 41 | } 42 | 43 | /// Indicates whether the product has an intro offer. 44 | public var hasIntroductoryOffer: Bool { 45 | subscription?.introductoryOffer != nil 46 | } 47 | 48 | /// Indicates whether the product has an intro offer which is trial 49 | public var hasTrial: Bool { 50 | guard let offer = subscription?.introductoryOffer else { return false } 51 | 52 | return offer.paymentMode == .freeTrial 53 | } 54 | 55 | /// Indicates whether the product has an intro offer which is pay as you go 56 | public var hasPayAsYouGoOffer: Bool { 57 | guard let offer = subscription?.introductoryOffer else { return false } 58 | 59 | return offer.paymentMode == .payAsYouGo 60 | } 61 | 62 | /// Returns the price of the product, considering eligibility for an introductory offer. 63 | /// 64 | /// - Parameter isEligibleForIntroductoryOffer: A boolean indicating if the user is eligible for an introductory offer. 65 | /// - Returns: A decimal representing the price. 66 | public func price(isEligibleForIntroductoryOffer: Bool) -> Decimal { 67 | guard isEligibleForIntroductoryOffer, 68 | let introductoryOffer = subscription?.introductoryOffer, 69 | introductoryOffer.paymentMode != .freeTrial else { 70 | return price 71 | } 72 | 73 | return introductoryOffer.price 74 | } 75 | 76 | public func finalPrice(isEligibleForIntroductoryOffer: Bool) -> Decimal { 77 | guard isEligibleForIntroductoryOffer, 78 | let introductoryOffer = subscription?.introductoryOffer else { 79 | return price 80 | } 81 | 82 | if introductoryOffer.paymentMode == .freeTrial { 83 | return 0 84 | } 85 | 86 | return introductoryOffer.price 87 | } 88 | 89 | /// Returns price per day. 90 | /// NOTE: Return 0 if it's not a subscription. 91 | public var priceInDay: Decimal { 92 | guard let periodInDays = subscription?.periodInDays, periodInDays > 0 else { return 0 } 93 | return price / Decimal(periodInDays) 94 | } 95 | 96 | /// Returns the localized subscription period as a string. 97 | /// NOTE: Can be empty string if subscription doesn't exist 98 | public var localizedPeriod: String { 99 | subscription?.localizedPeriod ?? "" 100 | } 101 | } 102 | 103 | extension Product { 104 | /// A localized string representation of `price`. 105 | /// 106 | /// - Note: This uses the locale from StoreKit/App Store, not the user's device locale. 107 | /// If you need to format the price using the user's locale, use the `price` property 108 | /// with a custom NumberFormatter configured with the user's locale. 109 | public var localizedPrice: String { 110 | displayPrice 111 | } 112 | 113 | /// Returns the localized price of the product, considering eligibility for an introductory offer. 114 | /// 115 | /// - Parameter isEligibleForIntroductoryOffer: A boolean indicating if the user is eligible for an introductory offer. 116 | /// - Returns: A localized string representation of `price`. 117 | /// - Note: This uses the locale from StoreKit/App Store, not the user's device locale. 118 | /// If you need to format the price using the user's locale, use the `price(isEligibleForIntroductoryOffer:)` 119 | /// method with a custom NumberFormatter configured with the user's locale. 120 | public func localizedPrice(isEligibleForIntroductoryOffer: Bool) -> String { 121 | guard isEligibleForIntroductoryOffer, 122 | let introductoryPrice = subscription?.introductoryOffer, 123 | introductoryPrice.paymentMode != .freeTrial else { 124 | return localizedPrice 125 | } 126 | 127 | return introductoryPrice.displayPrice 128 | } 129 | 130 | /// Returns the locale used for the product's price. 131 | public var priceLocale: Locale { 132 | priceFormatStyle.locale 133 | } 134 | } 135 | 136 | extension Product.SubscriptionInfo { 137 | /// Returns the localized subscription period as a string. 138 | public var localizedPeriod: String { 139 | subscriptionPeriod.localizedPeriod 140 | } 141 | 142 | /// Returns the subscription period in days. 143 | public var periodInDays: Int { 144 | subscriptionPeriod.periodInDays 145 | } 146 | } 147 | 148 | extension Product.SubscriptionPeriod { 149 | /// Returns the localized period of the subscription as a string. 150 | public var localizedPeriod: String { 151 | var localizedPeriod = PeriodFormatter.format( 152 | unit: unit.toCalendarUnit(), 153 | numberOfUnits: numberOfUnits 154 | ) 155 | 156 | let prefix = "1 " 157 | 158 | if let period = localizedPeriod, period.hasPrefix(prefix), numberOfUnits == 1 { 159 | localizedPeriod = String(period.dropFirst(prefix.count)) 160 | } 161 | 162 | return localizedPeriod?.replacingOccurrences(of: " ", with: "\u{00a0}") ?? "" 163 | } 164 | 165 | /// Returns the subscription period in days. 166 | public var periodInDays: Int { 167 | switch unit { 168 | case .day: 169 | 1 * numberOfUnits 170 | case .week: 171 | 7 * numberOfUnits 172 | case .month: 173 | 30 * numberOfUnits 174 | case .year: 175 | 365 * numberOfUnits 176 | @unknown default: 177 | fatalError("unknown period") 178 | } 179 | } 180 | 181 | /// The number of units that the period represents. 182 | public var numberOfUnits: Int { 183 | value 184 | } 185 | } 186 | 187 | extension Product.SubscriptionPeriod.Unit { 188 | /// Converts the subscription period unit to an `NSCalendar.Unit`. 189 | /// 190 | /// - Returns: An `Calendar.Component` representing the period unit. 191 | func toCalendarUnit() -> NSCalendar.Unit { 192 | switch self { 193 | case .day: 194 | .day 195 | case .month: 196 | .month 197 | case .week: 198 | .weekOfMonth 199 | case .year: 200 | .year 201 | @unknown default: 202 | .day 203 | } 204 | } 205 | } 206 | 207 | extension Product.SubscriptionOffer { 208 | 209 | /// Returns the localized price of the subscription offer as a string. 210 | public var localizedPrice: String { 211 | displayPrice 212 | } 213 | 214 | /// Returns the localized period of the subscription offer as a string. 215 | public var localizedPeriod: String { 216 | period.localizedPeriod 217 | } 218 | 219 | public var durationUnits: Int { 220 | period.numberOfUnits * periodCount 221 | } 222 | 223 | /// Returns the duration of the subscription offer in units. 224 | public var durationInDays: Int { 225 | period.periodInDays * periodCount 226 | } 227 | 228 | /// Returns the duration of the subscription offer in days. 229 | public var localizedDuration: String { 230 | var localizedDuration = PeriodFormatter.format( 231 | unit: period.unit.toCalendarUnit(), 232 | numberOfUnits: durationUnits 233 | ) 234 | let prefix = "1 " 235 | 236 | if let duration = localizedDuration, duration.hasPrefix(prefix), durationUnits == 1 { 237 | localizedDuration = String(duration.dropFirst(prefix.count)) 238 | } 239 | 240 | return localizedDuration?.replacingOccurrences(of: " ", with: "\u{00a0}") ?? "" 241 | } 242 | 243 | /// Returns the price per day of the subscription offer. 244 | public var priceInDay: Decimal { 245 | guard paymentMode != .freeTrial else { 246 | return 0 247 | } 248 | 249 | let periodInDays = period.periodInDays 250 | return price / Decimal(periodInDays) 251 | } 252 | } 253 | 254 | extension VerificationResult { 255 | package var payload: Transaction { 256 | get throws(MercatoError) { 257 | switch self { 258 | case .verified(let payload): 259 | return payload 260 | case .unverified(let payload, let error): 261 | throw MercatoError.failedVerification(payload: payload, error: error) 262 | } 263 | } 264 | } 265 | } 266 | 267 | extension Product.PurchaseOption { 268 | private enum Constants { 269 | static let kAdvancedCommerceDataKey = "advancedCommerceData" 270 | } 271 | 272 | public static func advancedCommerceData(_ data: Data) -> Product.PurchaseOption { 273 | Product.PurchaseOption.custom( 274 | key: Constants.kAdvancedCommerceDataKey, 275 | value: data 276 | ) 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /Tests/MercatoTests/ProductTests.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2021-2025 Pavel T 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import StoreKit 24 | import StoreKitTest 25 | import Testing 26 | @testable import Mercato 27 | 28 | @Suite("Product Extensions Tests") 29 | struct ProductTests { 30 | let mercato = Mercato() 31 | let session = TestSession.shared 32 | 33 | @Test("Regular monthly subscription properties") 34 | func testRegularMonthlySubscription() async throws { 35 | let products = try await mercato.retrieveSubscriptionProducts(productIds: [TestProducts.monthlyBasic]) 36 | 37 | guard let product = products.first else { 38 | Issue.record("Failed to retrieve monthly product") 39 | return 40 | } 41 | 42 | let subscription = product.subscription 43 | #expect(subscription != nil) 44 | 45 | let isEligible = await product.isEligibleForIntroOffer 46 | let hasActiveSubscription = await product.hasActiveSubscription 47 | 48 | #expect(isEligible == true) 49 | #expect(hasActiveSubscription == false) 50 | #expect(product.hasIntroductoryOffer == false) 51 | #expect(product.hasTrial == false) 52 | #expect(product.hasPayAsYouGoOffer == false) 53 | 54 | // Price checks 55 | #expect(product.price == Decimal.testPrice299) 56 | #expect(product.displayPrice == "$2.99") 57 | #expect(subscription?.subscriptionPeriod.value == 1) 58 | #expect(subscription?.subscriptionPeriod.unit == .month) 59 | #expect(subscription?.subscriptionPeriod.periodInDays == 30) 60 | } 61 | 62 | @Test("Annual subscription properties") 63 | func testAnnualSubscription() async throws { 64 | let products = try await mercato.retrieveSubscriptionProducts(productIds: [TestProducts.annualBasic]) 65 | 66 | guard let product = products.first else { 67 | Issue.record("Failed to retrieve annual product") 68 | return 69 | } 70 | 71 | #expect(product.subscription != nil) 72 | #expect(product.price == Decimal.testPrice2999) 73 | #expect(product.subscription?.subscriptionPeriod.unit == .year) 74 | #expect(product.subscription?.subscriptionPeriod.periodInDays == 365) 75 | } 76 | 77 | @Test("Monthly subscription with trial") 78 | func testMonthlyTrialSubscription() async throws { 79 | let products = try await mercato.retrieveSubscriptionProducts(productIds: [TestProducts.monthlyTrial]) 80 | 81 | guard let product = products.first else { 82 | Issue.record("Failed to retrieve monthly trial product") 83 | return 84 | } 85 | 86 | 87 | #expect(product.hasIntroductoryOffer == true) 88 | #expect(product.hasTrial == true) 89 | #expect(product.hasPayAsYouGoOffer == false) 90 | 91 | let introOffer = product.subscription?.introductoryOffer 92 | #expect(introOffer != nil) 93 | #expect(introOffer?.paymentMode == .freeTrial) 94 | #expect(introOffer?.period.unit == .week) 95 | #expect(introOffer?.period.value == 1) 96 | } 97 | 98 | @Test("Annual subscription with trial") 99 | func testAnnualTrialSubscription() async throws { 100 | let products = try await mercato.retrieveSubscriptionProducts(productIds: [TestProducts.annualTrial]) 101 | 102 | guard let product = products.first else { 103 | Issue.record("Failed to retrieve annual trial product") 104 | return 105 | } 106 | 107 | #expect(product.hasTrial == true) 108 | 109 | let introOffer = product.subscription?.introductoryOffer 110 | #expect(introOffer?.paymentMode == .freeTrial) 111 | #expect(introOffer?.period.unit == .month) 112 | #expect(introOffer?.period.value == 1) 113 | } 114 | 115 | @Test("Monthly subscription with intro price") 116 | func testMonthlyIntroSubscription() async throws { 117 | let products = try await mercato.retrieveSubscriptionProducts(productIds: [TestProducts.monthlyIntro]) 118 | 119 | guard let product = products.first else { 120 | Issue.record("Failed to retrieve monthly intro product") 121 | return 122 | } 123 | 124 | #expect(product.hasIntroductoryOffer == true) 125 | #expect(product.hasTrial == false) 126 | #expect(product.hasPayAsYouGoOffer == true) 127 | 128 | let introOffer = product.subscription?.introductoryOffer 129 | #expect(introOffer != nil) 130 | #expect(introOffer?.paymentMode == .payAsYouGo) 131 | #expect(introOffer?.price == Decimal.testPrice099) 132 | #expect(introOffer?.periodCount == 3) 133 | 134 | // Test intro price calculation 135 | let introDurationDays = introOffer?.durationInDays ?? 0 136 | #expect(introDurationDays == 90) // 3 months * 30 days 137 | } 138 | 139 | @Test("Price per day calculation") 140 | func testPricePerDay() async throws { 141 | let products = try await mercato.retrieveSubscriptionProducts(productIds: [TestProducts.monthlyBasic]) 142 | 143 | guard let product = products.first else { 144 | Issue.record("Failed to retrieve product") 145 | return 146 | } 147 | 148 | let pricePerDay = product.priceInDay 149 | let expectedPricePerDay = Decimal.testPrice299 / 30 150 | 151 | // Allow small difference due to decimal precision 152 | let difference = abs(pricePerDay - expectedPricePerDay) 153 | #expect(difference < Decimal(0.01)) 154 | } 155 | 156 | @Test("Localized price with intro offer") 157 | func testLocalizedPriceWithIntroOffer() async throws { 158 | let products = try await mercato.retrieveSubscriptionProducts(productIds: [TestProducts.monthlyIntro]) 159 | 160 | guard let product = products.first else { 161 | Issue.record("Failed to retrieve product") 162 | return 163 | } 164 | 165 | // When eligible for intro offer 166 | let introPrice = product.price(isEligibleForIntroductoryOffer: true) 167 | #expect(introPrice == Decimal.testPrice099) 168 | 169 | // When not eligible 170 | let regularPrice = product.price(isEligibleForIntroductoryOffer: false) 171 | #expect(regularPrice == product.price) 172 | } 173 | 174 | @Test("Final price with free trial") 175 | func testFinalPriceWithFreeTrial() async throws { 176 | let products = try await mercato.retrieveSubscriptionProducts(productIds: [TestProducts.monthlyTrial]) 177 | 178 | guard let product = products.first else { 179 | Issue.record("Failed to retrieve product") 180 | return 181 | } 182 | 183 | // Free trial should have final price of 0 184 | let finalPrice = product.finalPrice(isEligibleForIntroductoryOffer: true) 185 | #expect(finalPrice == Decimal(0)) 186 | 187 | // Without eligibility, should return regular price 188 | let regularPrice = product.finalPrice(isEligibleForIntroductoryOffer: false) 189 | #expect(regularPrice == product.price) 190 | } 191 | 192 | @Test("Consumable product properties") 193 | func testConsumableProduct() async throws { 194 | let products = try await mercato.retrieveSubscriptionProducts(productIds: [TestProducts.coins100]) 195 | 196 | guard let product = products.first else { 197 | Issue.record("Failed to retrieve consumable product") 198 | return 199 | } 200 | 201 | #expect(product.type == .consumable) 202 | #expect(product.subscription == nil) 203 | #expect(product.hasIntroductoryOffer == false) 204 | #expect(product.localizedPeriod == "") 205 | #expect(product.priceInDay == Decimal(0)) 206 | } 207 | 208 | @Test("Non-consumable product properties") 209 | func testNonConsumableProduct() async throws { 210 | let products = try await mercato.retrieveSubscriptionProducts(productIds: [TestProducts.removeAds]) 211 | 212 | guard let product = products.first else { 213 | Issue.record("Failed to retrieve non-consumable product") 214 | return 215 | } 216 | 217 | #expect(product.type == .nonConsumable) 218 | #expect(product.isFamilyShareable == true) 219 | #expect(product.subscription == nil) 220 | } 221 | 222 | @Test("Non-renewing subscription properties") 223 | func testNonRenewingSubscription() async throws { 224 | let products = try await mercato.retrieveSubscriptionProducts(productIds: [TestProducts.nonRenewingMonth]) 225 | 226 | guard let product = products.first else { 227 | Issue.record("Failed to retrieve non-renewing subscription") 228 | return 229 | } 230 | 231 | #expect(product.type == .nonRenewable) 232 | #expect(product.subscription == nil) 233 | #expect(product.hasIntroductoryOffer == false) 234 | } 235 | 236 | @Test("Localized period formatting") 237 | func testLocalizedPeriod() async throws { 238 | let products = try await mercato.retrieveSubscriptionProducts(productIds: [ 239 | TestProducts.weeklyBasic, 240 | TestProducts.monthlyBasic, 241 | TestProducts.annualBasic 242 | ]) 243 | 244 | for product in products { 245 | let period = product.localizedPeriod 246 | #expect(!period.isEmpty) 247 | 248 | // Check non-breaking space is used 249 | #expect(period.contains("\u{00a0}") || !period.contains(" ")) 250 | } 251 | } 252 | 253 | @Test("Localized duration for intro offers") 254 | func testLocalizedDuration() async throws { 255 | let products = try await mercato.retrieveSubscriptionProducts(productIds: [TestProducts.monthlyIntro]) 256 | 257 | guard let product = products.first, 258 | let introOffer = product.subscription?.introductoryOffer else { 259 | Issue.record("Failed to retrieve intro offer") 260 | return 261 | } 262 | 263 | let duration = introOffer.localizedDuration 264 | #expect(!duration.isEmpty) 265 | #expect(duration.contains("3") || duration.contains("three")) 266 | } 267 | } 268 | --------------------------------------------------------------------------------