├── 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 | [](http://mit-license.org)
8 | [](https://developer.apple.com/resources/)
9 | [](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 |
--------------------------------------------------------------------------------