Skip to content
This repository was archived by the owner on Oct 16, 2025. It is now read-only.

Commit 213af13

Browse files
authored
feat: support external purchase api with listener (#18)
Align apis with openiap-gql@1.0.12 which supports external billing. - hyodotdev/openiap-gql#15
1 parent d7c386e commit 213af13

File tree

5 files changed

+203
-110
lines changed

5 files changed

+203
-110
lines changed

Example/OpenIapExample/Screens/AllProductsView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ struct AllProductsView: View {
226226
}
227227

228228
HStack {
229-
Text(product.displayPrice ?? "--")
229+
Text(product.displayPrice)
230230
.font(.title2)
231231
.fontWeight(.bold)
232232
.foregroundColor(.blue)

Example/OpenIapExample/Screens/AlternativeBillingScreen.swift

Lines changed: 62 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ struct AlternativeBillingScreen: View {
131131
.autocapitalization(.none)
132132
.keyboardType(.URL)
133133

134-
Text("This URL will be opened when a user taps Purchase. Make sure the URL is valid and accessible.")
134+
Text("Tap Purchase on any product below. The ExternalPurchase API (iOS 18.2+) will show Apple's notice sheet before opening this URL.")
135135
.font(.caption)
136136
.foregroundColor(.secondary)
137137
}
@@ -221,14 +221,18 @@ struct AlternativeBillingScreen: View {
221221
)
222222
InstructionRow(
223223
number: "2",
224-
text: "Tap Purchase on any product"
224+
text: "Tap Purchase on any product below"
225225
)
226226
InstructionRow(
227227
number: "3",
228-
text: "User will be redirected to the external URL"
228+
text: "Apple's notice sheet appears (iOS 18.2+)"
229229
)
230230
InstructionRow(
231231
number: "4",
232+
text: "User taps Continue → Opens external URL"
233+
)
234+
InstructionRow(
235+
number: "5",
232236
text: "Complete purchase on your website"
233237
)
234238
}
@@ -239,7 +243,7 @@ struct AlternativeBillingScreen: View {
239243
.fontWeight(.semibold)
240244
.foregroundColor(AppColors.warning)
241245

242-
Text("• iOS 16.0 or later required\nValid external URL needed\nuseAlternativeBilling: true is set\nonPurchaseUpdated will NOT fire\nImplement deep link to return to app")
246+
Text("• iOS 18.2+ required for ExternalPurchase API\nApple's official alternative billing compliance\nNotice sheet shows App Store warning\nPurchase completes on external website\nDeep link needed to return to app")
243247
.font(.caption)
244248
.foregroundColor(.secondary)
245249
}
@@ -315,66 +319,75 @@ struct AlternativeBillingScreen: View {
315319
}
316320
}
317321

318-
// MARK: - Purchase Flow with Alternative Billing
322+
// MARK: - Purchase Flow with Alternative Billing (iOS 18.2+)
319323

320324
private func purchaseProduct(_ product: OpenIapProduct) {
321325
print("🛒 [AlternativeBilling] Starting alternative billing purchase for: \(product.id)")
322326
print("🌐 [AlternativeBilling] External URL: \(externalUrl)")
323327

324-
Task {
325-
do {
326-
let requestType: ProductQueryType = product.type == .subs ? .subs : .inApp
327-
328-
// Create request based on product type
329-
let request: RequestPurchaseProps.Request
330-
if requestType == .subs {
331-
let subscriptionProps = RequestSubscriptionIosProps(
332-
externalPurchaseUrl: externalUrl,
333-
sku: product.id
334-
)
335-
336-
request = .subscription(RequestSubscriptionPropsByPlatforms(
337-
ios: subscriptionProps
338-
))
339-
} else {
340-
let iosProps = RequestPurchaseIosProps(
341-
externalPurchaseUrl: externalUrl,
342-
sku: product.id
343-
)
344-
345-
request = .purchase(RequestPurchasePropsByPlatforms(
346-
ios: iosProps
347-
))
328+
if #available(iOS 18.2, *) {
329+
Task { await testExternalPurchaseFlow() }
330+
} else {
331+
errorMessage = "Alternative billing with ExternalPurchase API requires iOS 18.2 or later"
332+
showError = true
333+
}
334+
}
335+
336+
// MARK: - External Purchase Flow (iOS 18.2+)
337+
338+
@available(iOS 18.2, *)
339+
private func testExternalPurchaseFlow() async {
340+
print("🔷 [AlternativeBilling] Testing external purchase flow...")
341+
342+
do {
343+
// Step 1: Check if notice sheet can be presented
344+
let canPresent = try await OpenIapModule.shared.canPresentExternalPurchaseNoticeIOS()
345+
print("✅ [AlternativeBilling] Can present notice sheet: \(canPresent)")
346+
347+
guard canPresent else {
348+
await MainActor.run {
349+
errorMessage = "External purchase notice sheet is not available on this device"
350+
showError = true
348351
}
352+
return
353+
}
349354

350-
let params = RequestPurchaseProps(
351-
request: request,
352-
type: requestType,
353-
useAlternativeBilling: true
354-
)
355+
// Step 2: Present notice sheet
356+
let noticeResult = try await OpenIapModule.shared.presentExternalPurchaseNoticeSheetIOS()
357+
print("✅ [AlternativeBilling] Notice sheet result: \(noticeResult.result)")
355358

356-
_ = try await OpenIapModule.shared.requestPurchase(params)
359+
if noticeResult.result == .continue {
360+
// Step 3: Present external purchase link
361+
let linkResult = try await OpenIapModule.shared.presentExternalPurchaseLinkIOS(externalUrl)
362+
print("✅ [AlternativeBilling] Link result: \(linkResult.success)")
357363

358-
// When using external URL, the purchase is handled externally
359364
await MainActor.run {
360-
purchaseResultMessage = """
361-
🌐 Redirected to external URL
362-
Product: \(product.id)
363-
URL: \(externalUrl)
364-
365-
Complete the purchase on the external website.
366-
Note: onPurchaseUpdated will NOT be called.
367-
"""
365+
if linkResult.success {
366+
purchaseResultMessage = """
367+
🌐 External purchase flow completed
368+
User was redirected to: \(externalUrl)
369+
"""
370+
} else {
371+
purchaseResultMessage = """
372+
❌ External purchase link failed
373+
Error: \(linkResult.error ?? "Unknown error")
374+
"""
375+
}
368376
showPurchaseResult = true
369377
}
370-
371-
} catch {
372-
print("❌ [AlternativeBilling] Purchase failed: \(error.localizedDescription)")
378+
} else {
373379
await MainActor.run {
374-
errorMessage = "Alternative billing error: \(error.localizedDescription)"
375-
showError = true
380+
purchaseResultMessage = "User dismissed the notice sheet"
381+
showPurchaseResult = true
376382
}
377383
}
384+
385+
} catch {
386+
print("❌ [AlternativeBilling] External purchase flow error: \(error)")
387+
await MainActor.run {
388+
errorMessage = "External purchase flow error: \(error.localizedDescription)"
389+
showError = true
390+
}
378391
}
379392
}
380393

Sources/Models/Types.swift

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,19 @@ public enum ErrorCode: String, Codable, CaseIterable {
5757
case emptySkuList = "empty-sku-list"
5858
}
5959

60+
/// User actions on external purchase notice sheet (iOS 18.2+)
61+
public enum ExternalPurchaseNoticeAction: String, Codable, CaseIterable {
62+
/// User chose to continue to external purchase
63+
case `continue` = "continue"
64+
/// User dismissed the notice sheet
65+
case dismissed = "dismissed"
66+
}
67+
6068
public enum IapEvent: String, Codable, CaseIterable {
6169
case purchaseUpdated = "purchase-updated"
6270
case purchaseError = "purchase-error"
6371
case promotedProductIos = "promoted-product-ios"
72+
case userChoiceBillingAndroid = "user-choice-billing-android"
6473
}
6574

6675
public enum IapPlatform: String, Codable, CaseIterable {
@@ -217,6 +226,22 @@ public struct EntitlementIOS: Codable {
217226
public var transactionId: String
218227
}
219228

229+
/// Result of presenting an external purchase link (iOS 18.2+)
230+
public struct ExternalPurchaseLinkResultIOS: Codable {
231+
/// Optional error message if the presentation failed
232+
public var error: String?
233+
/// Whether the user completed the external purchase flow
234+
public var success: Bool
235+
}
236+
237+
/// Result of presenting external purchase notice sheet (iOS 18.2+)
238+
public struct ExternalPurchaseNoticeResultIOS: Codable {
239+
/// Optional error message if the presentation failed
240+
public var error: String?
241+
/// Notice result indicating user action
242+
public var result: ExternalPurchaseNoticeAction
243+
}
244+
220245
public enum FetchProductsResult {
221246
case products([Product]?)
222247
case subscriptions([ProductSubscription]?)
@@ -469,6 +494,15 @@ public struct SubscriptionStatusIOS: Codable {
469494
public var state: String
470495
}
471496

497+
/// User Choice Billing event details (Android)
498+
/// Fired when a user selects alternative billing in the User Choice Billing dialog
499+
public struct UserChoiceBillingDetails: Codable {
500+
/// Token that must be reported to Google Play within 24 hours
501+
public var externalTransactionToken: String
502+
/// List of product IDs selected by the user
503+
public var products: [String]
504+
}
505+
472506
public typealias VoidResult = Void
473507

474508
// MARK: - Input Objects
@@ -635,8 +669,6 @@ public struct RequestPurchaseIosProps: Codable {
635669
public var andDangerouslyFinishTransactionAutomatically: Bool?
636670
/// App account token for user tracking
637671
public var appAccountToken: String?
638-
/// External purchase URL for alternative billing (iOS)
639-
public var externalPurchaseUrl: String?
640672
/// Purchase quantity
641673
public var quantity: Int?
642674
/// Product SKU
@@ -647,14 +679,12 @@ public struct RequestPurchaseIosProps: Codable {
647679
public init(
648680
andDangerouslyFinishTransactionAutomatically: Bool? = nil,
649681
appAccountToken: String? = nil,
650-
externalPurchaseUrl: String? = nil,
651682
quantity: Int? = nil,
652683
sku: String,
653684
withOffer: DiscountOfferInputIOS? = nil
654685
) {
655686
self.andDangerouslyFinishTransactionAutomatically = andDangerouslyFinishTransactionAutomatically
656687
self.appAccountToken = appAccountToken
657-
self.externalPurchaseUrl = externalPurchaseUrl
658688
self.quantity = quantity
659689
self.sku = sku
660690
self.withOffer = withOffer
@@ -784,23 +814,19 @@ public struct RequestSubscriptionAndroidProps: Codable {
784814
public struct RequestSubscriptionIosProps: Codable {
785815
public var andDangerouslyFinishTransactionAutomatically: Bool?
786816
public var appAccountToken: String?
787-
/// External purchase URL for alternative billing (iOS)
788-
public var externalPurchaseUrl: String?
789817
public var quantity: Int?
790818
public var sku: String
791819
public var withOffer: DiscountOfferInputIOS?
792820

793821
public init(
794822
andDangerouslyFinishTransactionAutomatically: Bool? = nil,
795823
appAccountToken: String? = nil,
796-
externalPurchaseUrl: String? = nil,
797824
quantity: Int? = nil,
798825
sku: String,
799826
withOffer: DiscountOfferInputIOS? = nil
800827
) {
801828
self.andDangerouslyFinishTransactionAutomatically = andDangerouslyFinishTransactionAutomatically
802829
self.appAccountToken = appAccountToken
803-
self.externalPurchaseUrl = externalPurchaseUrl
804830
self.quantity = quantity
805831
self.sku = sku
806832
self.withOffer = withOffer
@@ -1155,6 +1181,10 @@ public protocol MutationResolver {
11551181
func initConnection(_ config: InitConnectionConfig?) async throws -> Bool
11561182
/// Present the App Store code redemption sheet
11571183
func presentCodeRedemptionSheetIOS() async throws -> Bool
1184+
/// Present external purchase custom link with StoreKit UI (iOS 18.2+)
1185+
func presentExternalPurchaseLinkIOS(_ url: String) async throws -> ExternalPurchaseLinkResultIOS
1186+
/// Present external purchase notice sheet (iOS 18.2+)
1187+
func presentExternalPurchaseNoticeSheetIOS() async throws -> ExternalPurchaseNoticeResultIOS
11581188
/// Initiate a purchase flow; rely on events for final state
11591189
func requestPurchase(_ params: RequestPurchaseProps) async throws -> RequestPurchaseResult?
11601190
/// Purchase the promoted product surfaced by the App Store
@@ -1178,6 +1208,8 @@ public protocol MutationResolver {
11781208

11791209
/// GraphQL root query operations.
11801210
public protocol QueryResolver {
1211+
/// Check if external purchase notice sheet can be presented (iOS 18.2+)
1212+
func canPresentExternalPurchaseNoticeIOS() async throws -> Bool
11811213
/// Get current StoreKit 2 entitlements (iOS 15+)
11821214
func currentEntitlementIOS(_ sku: String) async throws -> PurchaseIOS?
11831215
/// Retrieve products or subscriptions from the store
@@ -1222,6 +1254,9 @@ public protocol SubscriptionResolver {
12221254
func purchaseError() async throws -> PurchaseError
12231255
/// Fires when a purchase completes successfully or a pending purchase resolves
12241256
func purchaseUpdated() async throws -> Purchase
1257+
/// Fires when a user selects alternative billing in the User Choice Billing dialog (Android only)
1258+
/// Only triggered when the user selects alternative billing instead of Google Play billing
1259+
func userChoiceBillingAndroid() async throws -> UserChoiceBillingDetails
12251260
}
12261261

12271262
// MARK: - Root Operation Helpers
@@ -1239,6 +1274,8 @@ public typealias MutationEndConnectionHandler = () async throws -> Bool
12391274
public typealias MutationFinishTransactionHandler = (_ purchase: PurchaseInput, _ isConsumable: Bool?) async throws -> Void
12401275
public typealias MutationInitConnectionHandler = (_ config: InitConnectionConfig?) async throws -> Bool
12411276
public typealias MutationPresentCodeRedemptionSheetIOSHandler = () async throws -> Bool
1277+
public typealias MutationPresentExternalPurchaseLinkIOSHandler = (_ url: String) async throws -> ExternalPurchaseLinkResultIOS
1278+
public typealias MutationPresentExternalPurchaseNoticeSheetIOSHandler = () async throws -> ExternalPurchaseNoticeResultIOS
12421279
public typealias MutationRequestPurchaseHandler = (_ params: RequestPurchaseProps) async throws -> RequestPurchaseResult?
12431280
public typealias MutationRequestPurchaseOnPromotedProductIOSHandler = () async throws -> Bool
12441281
public typealias MutationRestorePurchasesHandler = () async throws -> Void
@@ -1259,6 +1296,8 @@ public struct MutationHandlers {
12591296
public var finishTransaction: MutationFinishTransactionHandler?
12601297
public var initConnection: MutationInitConnectionHandler?
12611298
public var presentCodeRedemptionSheetIOS: MutationPresentCodeRedemptionSheetIOSHandler?
1299+
public var presentExternalPurchaseLinkIOS: MutationPresentExternalPurchaseLinkIOSHandler?
1300+
public var presentExternalPurchaseNoticeSheetIOS: MutationPresentExternalPurchaseNoticeSheetIOSHandler?
12621301
public var requestPurchase: MutationRequestPurchaseHandler?
12631302
public var requestPurchaseOnPromotedProductIOS: MutationRequestPurchaseOnPromotedProductIOSHandler?
12641303
public var restorePurchases: MutationRestorePurchasesHandler?
@@ -1279,6 +1318,8 @@ public struct MutationHandlers {
12791318
finishTransaction: MutationFinishTransactionHandler? = nil,
12801319
initConnection: MutationInitConnectionHandler? = nil,
12811320
presentCodeRedemptionSheetIOS: MutationPresentCodeRedemptionSheetIOSHandler? = nil,
1321+
presentExternalPurchaseLinkIOS: MutationPresentExternalPurchaseLinkIOSHandler? = nil,
1322+
presentExternalPurchaseNoticeSheetIOS: MutationPresentExternalPurchaseNoticeSheetIOSHandler? = nil,
12821323
requestPurchase: MutationRequestPurchaseHandler? = nil,
12831324
requestPurchaseOnPromotedProductIOS: MutationRequestPurchaseOnPromotedProductIOSHandler? = nil,
12841325
restorePurchases: MutationRestorePurchasesHandler? = nil,
@@ -1298,6 +1339,8 @@ public struct MutationHandlers {
12981339
self.finishTransaction = finishTransaction
12991340
self.initConnection = initConnection
13001341
self.presentCodeRedemptionSheetIOS = presentCodeRedemptionSheetIOS
1342+
self.presentExternalPurchaseLinkIOS = presentExternalPurchaseLinkIOS
1343+
self.presentExternalPurchaseNoticeSheetIOS = presentExternalPurchaseNoticeSheetIOS
13011344
self.requestPurchase = requestPurchase
13021345
self.requestPurchaseOnPromotedProductIOS = requestPurchaseOnPromotedProductIOS
13031346
self.restorePurchases = restorePurchases
@@ -1310,6 +1353,7 @@ public struct MutationHandlers {
13101353

13111354
// MARK: - Query Helpers
13121355

1356+
public typealias QueryCanPresentExternalPurchaseNoticeIOSHandler = () async throws -> Bool
13131357
public typealias QueryCurrentEntitlementIOSHandler = (_ sku: String) async throws -> PurchaseIOS?
13141358
public typealias QueryFetchProductsHandler = (_ params: ProductRequest) async throws -> FetchProductsResult
13151359
public typealias QueryGetActiveSubscriptionsHandler = (_ subscriptionIds: [String]?) async throws -> [ActiveSubscription]
@@ -1329,6 +1373,7 @@ public typealias QuerySubscriptionStatusIOSHandler = (_ sku: String) async throw
13291373
public typealias QueryValidateReceiptIOSHandler = (_ options: ReceiptValidationProps) async throws -> ReceiptValidationResultIOS
13301374

13311375
public struct QueryHandlers {
1376+
public var canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler?
13321377
public var currentEntitlementIOS: QueryCurrentEntitlementIOSHandler?
13331378
public var fetchProducts: QueryFetchProductsHandler?
13341379
public var getActiveSubscriptions: QueryGetActiveSubscriptionsHandler?
@@ -1348,6 +1393,7 @@ public struct QueryHandlers {
13481393
public var validateReceiptIOS: QueryValidateReceiptIOSHandler?
13491394

13501395
public init(
1396+
canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? = nil,
13511397
currentEntitlementIOS: QueryCurrentEntitlementIOSHandler? = nil,
13521398
fetchProducts: QueryFetchProductsHandler? = nil,
13531399
getActiveSubscriptions: QueryGetActiveSubscriptionsHandler? = nil,
@@ -1366,6 +1412,7 @@ public struct QueryHandlers {
13661412
subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? = nil,
13671413
validateReceiptIOS: QueryValidateReceiptIOSHandler? = nil
13681414
) {
1415+
self.canPresentExternalPurchaseNoticeIOS = canPresentExternalPurchaseNoticeIOS
13691416
self.currentEntitlementIOS = currentEntitlementIOS
13701417
self.fetchProducts = fetchProducts
13711418
self.getActiveSubscriptions = getActiveSubscriptions
@@ -1391,19 +1438,23 @@ public struct QueryHandlers {
13911438
public typealias SubscriptionPromotedProductIOSHandler = () async throws -> String
13921439
public typealias SubscriptionPurchaseErrorHandler = () async throws -> PurchaseError
13931440
public typealias SubscriptionPurchaseUpdatedHandler = () async throws -> Purchase
1441+
public typealias SubscriptionUserChoiceBillingAndroidHandler = () async throws -> UserChoiceBillingDetails
13941442

13951443
public struct SubscriptionHandlers {
13961444
public var promotedProductIOS: SubscriptionPromotedProductIOSHandler?
13971445
public var purchaseError: SubscriptionPurchaseErrorHandler?
13981446
public var purchaseUpdated: SubscriptionPurchaseUpdatedHandler?
1447+
public var userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler?
13991448

14001449
public init(
14011450
promotedProductIOS: SubscriptionPromotedProductIOSHandler? = nil,
14021451
purchaseError: SubscriptionPurchaseErrorHandler? = nil,
1403-
purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = nil
1452+
purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = nil,
1453+
userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = nil
14041454
) {
14051455
self.promotedProductIOS = promotedProductIOS
14061456
self.purchaseError = purchaseError
14071457
self.purchaseUpdated = purchaseUpdated
1458+
self.userChoiceBillingAndroid = userChoiceBillingAndroid
14081459
}
14091460
}

0 commit comments

Comments
 (0)