Skip to content

Commit a06b67b

Browse files
authored
[in_app_purchase_storekit] Add support to win back offers / promotional offers (#8474)
**Adds StoreKit2 support for**: - **Win Back Offers** (requires Xcode 16 or later) - New `isWinBackOfferEligible` method for eligibility checks - **Promotional Offers** - Fixes introductory pricing handling in promotional offers list - Fixes `appAccountToken` handling for StoreKit2 purchases Fixes: flutter/flutter#161393 flutter/flutter#160826
1 parent da1d32f commit a06b67b

File tree

20 files changed

+1502
-108
lines changed

20 files changed

+1502
-108
lines changed

packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1-
## NEXT
1+
## 0.4.1
22

33
* Updates minimum supported SDK version to Flutter 3.27/Dart 3.6.
4+
* Adds **Win Back Offers** support for StoreKit2:
5+
- Includes new `isWinBackOfferEligible` function for eligibility verification
6+
* Adds **Promotional Offers** support in StoreKit2 purchases
7+
* Fixes introductory pricing handling in promotional offers list in StoreKit2
8+
* Ensures proper `appAccountToken` handling for StoreKit2 purchases
49

510
## 0.4.0
611

packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,36 @@ extension InAppPurchasePlugin: InAppPurchase2API {
5353
return completion(.failure(error))
5454
}
5555

56-
let result = try await product.purchase(options: [])
56+
var purchaseOptions: Set<Product.PurchaseOption> = []
57+
58+
if let appAccountToken = options?.appAccountToken,
59+
let accountTokenUUID = UUID(uuidString: appAccountToken)
60+
{
61+
purchaseOptions.insert(.appAccountToken(accountTokenUUID))
62+
}
63+
64+
if #available(iOS 17.4, macOS 14.4, *) {
65+
if let promotionalOffer = options?.promotionalOffer {
66+
purchaseOptions.insert(
67+
.promotionalOffer(
68+
offerID: promotionalOffer.promotionalOfferId,
69+
signature: promotionalOffer.promotionalOfferSignature.convertToSignature
70+
)
71+
)
72+
}
73+
}
74+
75+
if #available(iOS 18.0, macOS 15.0, *) {
76+
if let winBackOfferId = options?.winBackOfferId,
77+
let winBackOffer = product.subscription?.winBackOffers.first(where: {
78+
$0.id == winBackOfferId
79+
})
80+
{
81+
purchaseOptions.insert(.winBackOffer(winBackOffer))
82+
}
83+
}
84+
85+
let result = try await product.purchase(options: purchaseOptions)
5786

5887
switch result {
5988
case .success(let verification):
@@ -88,6 +117,70 @@ extension InAppPurchasePlugin: InAppPurchase2API {
88117
}
89118
}
90119

120+
/// Checks if the user is eligible for a specific win back offer.
121+
///
122+
/// - Parameters:
123+
/// - productId: The product ID associated with the offer.
124+
/// - offerId: The ID of the win back offer.
125+
/// - completion: Returns `Bool` for eligibility or `Error` on failure.
126+
///
127+
/// - Availability: iOS 18.0+, macOS 15.0+, Swift 6.0+ (Xcode 16+).
128+
func isWinBackOfferEligible(
129+
productId: String,
130+
offerId: String,
131+
completion: @escaping (Result<Bool, Error>) -> Void
132+
) {
133+
if #available(iOS 18.0, macOS 15.0, *) {
134+
Task {
135+
do {
136+
guard let product = try await Product.products(for: [productId]).first else {
137+
completion(
138+
.failure(
139+
PigeonError(
140+
code: "storekit2_failed_to_fetch_product",
141+
message: "Storekit has failed to fetch this product.",
142+
details: "Product ID: \(productId)")))
143+
return
144+
}
145+
146+
guard let subscription = product.subscription else {
147+
completion(
148+
.failure(
149+
PigeonError(
150+
code: "storekit2_not_subscription",
151+
message: "Product is not a subscription",
152+
details: "Product ID: \(productId)")))
153+
return
154+
}
155+
156+
let isEligible = try await subscription.status.contains { status in
157+
if case .verified(let renewalInfo) = status.renewalInfo {
158+
return renewalInfo.eligibleWinBackOfferIDs.contains(offerId)
159+
}
160+
return false
161+
}
162+
163+
completion(.success(isEligible))
164+
165+
} catch {
166+
completion(
167+
.failure(
168+
PigeonError(
169+
code: "storekit2_eligibility_check_failed",
170+
message: "Failed to check offer eligibility: \(error.localizedDescription)",
171+
details: "Product ID: \(productId), Error: \(error)")))
172+
}
173+
}
174+
} else {
175+
completion(
176+
.failure(
177+
PigeonError(
178+
code: "storekit2_unsupported_platform_version",
179+
message: "Win back offers require iOS 18+ or macOS 15.0+",
180+
details: nil)))
181+
}
182+
}
183+
91184
/// Wrapper method around StoreKit2's transactions() method
92185
/// https://developer.apple.com/documentation/storekit/product/3851116-products
93186
func transactions(

packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Translators.swift

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,23 @@ extension Product.ProductType {
5252
@available(iOS 15.0, macOS 12.0, *)
5353
extension Product.SubscriptionInfo {
5454
var convertToPigeon: SK2SubscriptionInfoMessage {
55+
var allOffers: [SK2SubscriptionOfferMessage] = []
56+
57+
if #available(iOS 18.0, macOS 15.0, *) {
58+
allOffers.append(contentsOf: winBackOffers.map { $0.convertToPigeon })
59+
}
60+
61+
allOffers.append(contentsOf: promotionalOffers.map { $0.convertToPigeon })
62+
63+
if let introductory = introductoryOffer {
64+
allOffers.append(introductory.convertToPigeon)
65+
}
66+
5567
return SK2SubscriptionInfoMessage(
56-
promotionalOffers: promotionalOffers.map({ $0.convertToPigeon }),
68+
promotionalOffers: allOffers,
5769
subscriptionGroupID: subscriptionGroupID,
58-
subscriptionPeriod: subscriptionPeriod.convertToPigeon)
70+
subscriptionPeriod: subscriptionPeriod.convertToPigeon
71+
)
5972
}
6073
}
6174

@@ -90,6 +103,33 @@ extension SK2SubscriptionOfferMessage: Equatable {
90103
}
91104
}
92105

106+
extension SK2SubscriptionOfferSignatureMessage {
107+
@available(iOS 17.4, macOS 14.4, *)
108+
var convertToSignature: Product.SubscriptionOffer.Signature {
109+
return Product.SubscriptionOffer.Signature(
110+
keyID: keyID,
111+
nonce: nonceAsUUID,
112+
timestamp: Int(timestamp),
113+
signature: signatureAsData
114+
)
115+
}
116+
117+
var nonceAsUUID: UUID {
118+
guard let uuid = UUID(uuidString: nonce) else {
119+
fatalError("Invalid UUID format for nonce: \(nonce)")
120+
}
121+
return uuid
122+
}
123+
124+
var signatureAsData: Data {
125+
guard let data = Data(base64Encoded: signature) else {
126+
fatalError("Invalid Base64 format for signature: \(signature)")
127+
}
128+
return data
129+
}
130+
131+
}
132+
93133
@available(iOS 15.0, macOS 12.0, *)
94134
extension Product.SubscriptionOffer.OfferType {
95135
var convertToPigeon: SK2SubscriptionOfferTypeMessage {
@@ -99,7 +139,12 @@ extension Product.SubscriptionOffer.OfferType {
99139
case .promotional:
100140
return SK2SubscriptionOfferTypeMessage.promotional
101141
default:
102-
fatalError("An unknown OfferType was passed in")
142+
if #available(iOS 18.0, macOS 15.0, *) {
143+
if self == .winBack {
144+
return SK2SubscriptionOfferTypeMessage.winBack
145+
}
146+
}
147+
fatalError("An unknown or unsupported OfferType was passed in")
103148
}
104149
}
105150
}

0 commit comments

Comments
 (0)