From 345e7ad265f0750c86fd0933c93b91fd98e1bb46 Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 4 Oct 2025 01:18:33 +0900 Subject: [PATCH 1/2] feat: support external purchase api with listener --- .../Screens/AllProductsView.swift | 2 +- .../Screens/AlternativeBillingScreen.swift | 111 ++++++++------- Sources/Models/Types.swift | 68 +++++++-- Sources/OpenIapModule.swift | 129 +++++++++++------- openiap-versions.json | 2 +- 5 files changed, 202 insertions(+), 110 deletions(-) diff --git a/Example/OpenIapExample/Screens/AllProductsView.swift b/Example/OpenIapExample/Screens/AllProductsView.swift index 763b3a4..6796dde 100644 --- a/Example/OpenIapExample/Screens/AllProductsView.swift +++ b/Example/OpenIapExample/Screens/AllProductsView.swift @@ -226,7 +226,7 @@ struct AllProductsView: View { } HStack { - Text(product.displayPrice ?? "--") + Text(product.displayPrice) .font(.title2) .fontWeight(.bold) .foregroundColor(.blue) diff --git a/Example/OpenIapExample/Screens/AlternativeBillingScreen.swift b/Example/OpenIapExample/Screens/AlternativeBillingScreen.swift index 571c2e6..c8db320 100644 --- a/Example/OpenIapExample/Screens/AlternativeBillingScreen.swift +++ b/Example/OpenIapExample/Screens/AlternativeBillingScreen.swift @@ -131,7 +131,7 @@ struct AlternativeBillingScreen: View { .autocapitalization(.none) .keyboardType(.URL) - Text("This URL will be opened when a user taps Purchase. Make sure the URL is valid and accessible.") + Text("Tap Purchase on any product below. The ExternalPurchase API (iOS 18.2+) will show Apple's notice sheet before opening this URL.") .font(.caption) .foregroundColor(.secondary) } @@ -221,14 +221,18 @@ struct AlternativeBillingScreen: View { ) InstructionRow( number: "2", - text: "Tap Purchase on any product" + text: "Tap Purchase on any product below" ) InstructionRow( number: "3", - text: "User will be redirected to the external URL" + text: "Apple's notice sheet appears (iOS 18.2+)" ) InstructionRow( number: "4", + text: "User taps Continue → Opens external URL" + ) + InstructionRow( + number: "5", text: "Complete purchase on your website" ) } @@ -239,7 +243,7 @@ struct AlternativeBillingScreen: View { .fontWeight(.semibold) .foregroundColor(AppColors.warning) - Text("• iOS 16.0 or later required\n• Valid external URL needed\n• useAlternativeBilling: true is set\n• onPurchaseUpdated will NOT fire\n• Implement deep link to return to app") + Text("• iOS 18.2+ required for ExternalPurchase API\n• Apple's official alternative billing compliance\n• Notice sheet shows App Store warning\n• Purchase completes on external website\n• Deep link needed to return to app") .font(.caption) .foregroundColor(.secondary) } @@ -315,66 +319,75 @@ struct AlternativeBillingScreen: View { } } - // MARK: - Purchase Flow with Alternative Billing + // MARK: - Purchase Flow with Alternative Billing (iOS 18.2+) private func purchaseProduct(_ product: OpenIapProduct) { print("🛒 [AlternativeBilling] Starting alternative billing purchase for: \(product.id)") print("🌐 [AlternativeBilling] External URL: \(externalUrl)") - Task { - do { - let requestType: ProductQueryType = product.type == .subs ? .subs : .inApp - - // Create request based on product type - let request: RequestPurchaseProps.Request - if requestType == .subs { - let subscriptionProps = RequestSubscriptionIosProps( - externalPurchaseUrl: externalUrl, - sku: product.id - ) - - request = .subscription(RequestSubscriptionPropsByPlatforms( - ios: subscriptionProps - )) - } else { - let iosProps = RequestPurchaseIosProps( - externalPurchaseUrl: externalUrl, - sku: product.id - ) - - request = .purchase(RequestPurchasePropsByPlatforms( - ios: iosProps - )) + if #available(iOS 18.2, *) { + Task { await testExternalPurchaseFlow() } + } else { + errorMessage = "Alternative billing with ExternalPurchase API requires iOS 18.2 or later" + showError = true + } + } + + // MARK: - External Purchase Flow (iOS 18.2+) + + @available(iOS 18.2, *) + private func testExternalPurchaseFlow() async { + print("🔷 [AlternativeBilling] Testing external purchase flow...") + + do { + // Step 1: Check if notice sheet can be presented + let canPresent = try await OpenIapModule.shared.canPresentExternalPurchaseNoticeIOS() + print("✅ [AlternativeBilling] Can present notice sheet: \(canPresent)") + + guard canPresent else { + await MainActor.run { + errorMessage = "External purchase notice sheet is not available on this device" + showError = true } + return + } - let params = RequestPurchaseProps( - request: request, - type: requestType, - useAlternativeBilling: true - ) + // Step 2: Present notice sheet + let noticeResult = try await OpenIapModule.shared.presentExternalPurchaseNoticeSheetIOS() + print("✅ [AlternativeBilling] Notice sheet result: \(noticeResult.result)") - _ = try await OpenIapModule.shared.requestPurchase(params) + if noticeResult.result == .continue { + // Step 3: Present external purchase link + let linkResult = try await OpenIapModule.shared.presentExternalPurchaseLinkIOS(externalUrl) + print("✅ [AlternativeBilling] Link result: \(linkResult.success)") - // When using external URL, the purchase is handled externally await MainActor.run { - purchaseResultMessage = """ - 🌐 Redirected to external URL - Product: \(product.id) - URL: \(externalUrl) - - Complete the purchase on the external website. - Note: onPurchaseUpdated will NOT be called. - """ + if linkResult.success { + purchaseResultMessage = """ + 🌐 External purchase flow completed + User was redirected to: \(externalUrl) + """ + } else { + purchaseResultMessage = """ + ❌ External purchase link failed + Error: \(linkResult.error ?? "Unknown error") + """ + } showPurchaseResult = true } - - } catch { - print("❌ [AlternativeBilling] Purchase failed: \(error.localizedDescription)") + } else { await MainActor.run { - errorMessage = "Alternative billing error: \(error.localizedDescription)" - showError = true + purchaseResultMessage = "User dismissed the notice sheet" + showPurchaseResult = true } } + + } catch { + print("❌ [AlternativeBilling] External purchase flow error: \(error)") + await MainActor.run { + errorMessage = "External purchase flow error: \(error.localizedDescription)" + showError = true + } } } diff --git a/Sources/Models/Types.swift b/Sources/Models/Types.swift index 2f70e0a..4217407 100644 --- a/Sources/Models/Types.swift +++ b/Sources/Models/Types.swift @@ -57,6 +57,14 @@ public enum ErrorCode: String, Codable, CaseIterable { case emptySkuList = "empty-sku-list" } +/// User actions on external purchase notice sheet (iOS 18.2+) +public enum ExternalPurchaseNoticeAction: String, Codable, CaseIterable { + /// User chose to continue to external purchase + case `continue` = "continue" + /// User dismissed the notice sheet + case dismissed = "dismissed" +} + public enum IapEvent: String, Codable, CaseIterable { case purchaseUpdated = "purchase-updated" case purchaseError = "purchase-error" @@ -217,6 +225,22 @@ public struct EntitlementIOS: Codable { public var transactionId: String } +/// Result of presenting an external purchase link (iOS 18.2+) +public struct ExternalPurchaseLinkResultIOS: Codable { + /// Optional error message if the presentation failed + public var error: String? + /// Whether the user completed the external purchase flow + public var success: Bool +} + +/// Result of presenting external purchase notice sheet (iOS 18.2+) +public struct ExternalPurchaseNoticeResultIOS: Codable { + /// Optional error message if the presentation failed + public var error: String? + /// Notice result indicating user action + public var result: ExternalPurchaseNoticeAction +} + public enum FetchProductsResult { case products([Product]?) case subscriptions([ProductSubscription]?) @@ -469,6 +493,15 @@ public struct SubscriptionStatusIOS: Codable { public var state: String } +/// User Choice Billing event details (Android) +/// Fired when a user selects alternative billing in the User Choice Billing dialog +public struct UserChoiceBillingDetails: Codable { + /// Token that must be reported to Google Play within 24 hours + public var externalTransactionToken: String + /// List of product IDs selected by the user + public var products: [String] +} + public typealias VoidResult = Void // MARK: - Input Objects @@ -635,8 +668,6 @@ public struct RequestPurchaseIosProps: Codable { public var andDangerouslyFinishTransactionAutomatically: Bool? /// App account token for user tracking public var appAccountToken: String? - /// External purchase URL for alternative billing (iOS) - public var externalPurchaseUrl: String? /// Purchase quantity public var quantity: Int? /// Product SKU @@ -647,14 +678,12 @@ public struct RequestPurchaseIosProps: Codable { public init( andDangerouslyFinishTransactionAutomatically: Bool? = nil, appAccountToken: String? = nil, - externalPurchaseUrl: String? = nil, quantity: Int? = nil, sku: String, withOffer: DiscountOfferInputIOS? = nil ) { self.andDangerouslyFinishTransactionAutomatically = andDangerouslyFinishTransactionAutomatically self.appAccountToken = appAccountToken - self.externalPurchaseUrl = externalPurchaseUrl self.quantity = quantity self.sku = sku self.withOffer = withOffer @@ -784,8 +813,6 @@ public struct RequestSubscriptionAndroidProps: Codable { public struct RequestSubscriptionIosProps: Codable { public var andDangerouslyFinishTransactionAutomatically: Bool? public var appAccountToken: String? - /// External purchase URL for alternative billing (iOS) - public var externalPurchaseUrl: String? public var quantity: Int? public var sku: String public var withOffer: DiscountOfferInputIOS? @@ -793,14 +820,12 @@ public struct RequestSubscriptionIosProps: Codable { public init( andDangerouslyFinishTransactionAutomatically: Bool? = nil, appAccountToken: String? = nil, - externalPurchaseUrl: String? = nil, quantity: Int? = nil, sku: String, withOffer: DiscountOfferInputIOS? = nil ) { self.andDangerouslyFinishTransactionAutomatically = andDangerouslyFinishTransactionAutomatically self.appAccountToken = appAccountToken - self.externalPurchaseUrl = externalPurchaseUrl self.quantity = quantity self.sku = sku self.withOffer = withOffer @@ -1155,6 +1180,10 @@ public protocol MutationResolver { func initConnection(_ config: InitConnectionConfig?) async throws -> Bool /// Present the App Store code redemption sheet func presentCodeRedemptionSheetIOS() async throws -> Bool + /// Present external purchase custom link with StoreKit UI (iOS 18.2+) + func presentExternalPurchaseLinkIOS(_ url: String) async throws -> ExternalPurchaseLinkResultIOS + /// Present external purchase notice sheet (iOS 18.2+) + func presentExternalPurchaseNoticeSheetIOS() async throws -> ExternalPurchaseNoticeResultIOS /// Initiate a purchase flow; rely on events for final state func requestPurchase(_ params: RequestPurchaseProps) async throws -> RequestPurchaseResult? /// Purchase the promoted product surfaced by the App Store @@ -1178,6 +1207,8 @@ public protocol MutationResolver { /// GraphQL root query operations. public protocol QueryResolver { + /// Check if external purchase notice sheet can be presented (iOS 18.2+) + func canPresentExternalPurchaseNoticeIOS() async throws -> Bool /// Get current StoreKit 2 entitlements (iOS 15+) func currentEntitlementIOS(_ sku: String) async throws -> PurchaseIOS? /// Retrieve products or subscriptions from the store @@ -1222,6 +1253,9 @@ public protocol SubscriptionResolver { func purchaseError() async throws -> PurchaseError /// Fires when a purchase completes successfully or a pending purchase resolves func purchaseUpdated() async throws -> Purchase + /// Fires when a user selects alternative billing in the User Choice Billing dialog (Android only) + /// Only triggered when the user selects alternative billing instead of Google Play billing + func userChoiceBillingAndroid() async throws -> UserChoiceBillingDetails } // MARK: - Root Operation Helpers @@ -1239,6 +1273,8 @@ public typealias MutationEndConnectionHandler = () async throws -> Bool public typealias MutationFinishTransactionHandler = (_ purchase: PurchaseInput, _ isConsumable: Bool?) async throws -> Void public typealias MutationInitConnectionHandler = (_ config: InitConnectionConfig?) async throws -> Bool public typealias MutationPresentCodeRedemptionSheetIOSHandler = () async throws -> Bool +public typealias MutationPresentExternalPurchaseLinkIOSHandler = (_ url: String) async throws -> ExternalPurchaseLinkResultIOS +public typealias MutationPresentExternalPurchaseNoticeSheetIOSHandler = () async throws -> ExternalPurchaseNoticeResultIOS public typealias MutationRequestPurchaseHandler = (_ params: RequestPurchaseProps) async throws -> RequestPurchaseResult? public typealias MutationRequestPurchaseOnPromotedProductIOSHandler = () async throws -> Bool public typealias MutationRestorePurchasesHandler = () async throws -> Void @@ -1259,6 +1295,8 @@ public struct MutationHandlers { public var finishTransaction: MutationFinishTransactionHandler? public var initConnection: MutationInitConnectionHandler? public var presentCodeRedemptionSheetIOS: MutationPresentCodeRedemptionSheetIOSHandler? + public var presentExternalPurchaseLinkIOS: MutationPresentExternalPurchaseLinkIOSHandler? + public var presentExternalPurchaseNoticeSheetIOS: MutationPresentExternalPurchaseNoticeSheetIOSHandler? public var requestPurchase: MutationRequestPurchaseHandler? public var requestPurchaseOnPromotedProductIOS: MutationRequestPurchaseOnPromotedProductIOSHandler? public var restorePurchases: MutationRestorePurchasesHandler? @@ -1279,6 +1317,8 @@ public struct MutationHandlers { finishTransaction: MutationFinishTransactionHandler? = nil, initConnection: MutationInitConnectionHandler? = nil, presentCodeRedemptionSheetIOS: MutationPresentCodeRedemptionSheetIOSHandler? = nil, + presentExternalPurchaseLinkIOS: MutationPresentExternalPurchaseLinkIOSHandler? = nil, + presentExternalPurchaseNoticeSheetIOS: MutationPresentExternalPurchaseNoticeSheetIOSHandler? = nil, requestPurchase: MutationRequestPurchaseHandler? = nil, requestPurchaseOnPromotedProductIOS: MutationRequestPurchaseOnPromotedProductIOSHandler? = nil, restorePurchases: MutationRestorePurchasesHandler? = nil, @@ -1298,6 +1338,8 @@ public struct MutationHandlers { self.finishTransaction = finishTransaction self.initConnection = initConnection self.presentCodeRedemptionSheetIOS = presentCodeRedemptionSheetIOS + self.presentExternalPurchaseLinkIOS = presentExternalPurchaseLinkIOS + self.presentExternalPurchaseNoticeSheetIOS = presentExternalPurchaseNoticeSheetIOS self.requestPurchase = requestPurchase self.requestPurchaseOnPromotedProductIOS = requestPurchaseOnPromotedProductIOS self.restorePurchases = restorePurchases @@ -1310,6 +1352,7 @@ public struct MutationHandlers { // MARK: - Query Helpers +public typealias QueryCanPresentExternalPurchaseNoticeIOSHandler = () async throws -> Bool public typealias QueryCurrentEntitlementIOSHandler = (_ sku: String) async throws -> PurchaseIOS? public typealias QueryFetchProductsHandler = (_ params: ProductRequest) async throws -> FetchProductsResult public typealias QueryGetActiveSubscriptionsHandler = (_ subscriptionIds: [String]?) async throws -> [ActiveSubscription] @@ -1329,6 +1372,7 @@ public typealias QuerySubscriptionStatusIOSHandler = (_ sku: String) async throw public typealias QueryValidateReceiptIOSHandler = (_ options: ReceiptValidationProps) async throws -> ReceiptValidationResultIOS public struct QueryHandlers { + public var canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? public var currentEntitlementIOS: QueryCurrentEntitlementIOSHandler? public var fetchProducts: QueryFetchProductsHandler? public var getActiveSubscriptions: QueryGetActiveSubscriptionsHandler? @@ -1348,6 +1392,7 @@ public struct QueryHandlers { public var validateReceiptIOS: QueryValidateReceiptIOSHandler? public init( + canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? = nil, currentEntitlementIOS: QueryCurrentEntitlementIOSHandler? = nil, fetchProducts: QueryFetchProductsHandler? = nil, getActiveSubscriptions: QueryGetActiveSubscriptionsHandler? = nil, @@ -1366,6 +1411,7 @@ public struct QueryHandlers { subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? = nil, validateReceiptIOS: QueryValidateReceiptIOSHandler? = nil ) { + self.canPresentExternalPurchaseNoticeIOS = canPresentExternalPurchaseNoticeIOS self.currentEntitlementIOS = currentEntitlementIOS self.fetchProducts = fetchProducts self.getActiveSubscriptions = getActiveSubscriptions @@ -1391,19 +1437,23 @@ public struct QueryHandlers { public typealias SubscriptionPromotedProductIOSHandler = () async throws -> String public typealias SubscriptionPurchaseErrorHandler = () async throws -> PurchaseError public typealias SubscriptionPurchaseUpdatedHandler = () async throws -> Purchase +public typealias SubscriptionUserChoiceBillingAndroidHandler = () async throws -> UserChoiceBillingDetails public struct SubscriptionHandlers { public var promotedProductIOS: SubscriptionPromotedProductIOSHandler? public var purchaseError: SubscriptionPurchaseErrorHandler? public var purchaseUpdated: SubscriptionPurchaseUpdatedHandler? + public var userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? public init( promotedProductIOS: SubscriptionPromotedProductIOSHandler? = nil, purchaseError: SubscriptionPurchaseErrorHandler? = nil, - purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = nil + purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = nil, + userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = nil ) { self.promotedProductIOS = promotedProductIOS self.purchaseError = purchaseError self.purchaseUpdated = purchaseUpdated + self.userChoiceBillingAndroid = userChoiceBillingAndroid } } diff --git a/Sources/OpenIapModule.swift b/Sources/OpenIapModule.swift index 6443683..d1ae01b 100644 --- a/Sources/OpenIapModule.swift +++ b/Sources/OpenIapModule.swift @@ -169,25 +169,6 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { try await ensureConnection() let iosProps = try resolveIosPurchaseProps(from: params) let sku = iosProps.sku - - // Check for alternative billing with external purchase URL - if let externalUrl = iosProps.externalPurchaseUrl, - params.useAlternativeBilling == true { - #if os(iOS) - if #available(iOS 16.0, *) { - return try await handleExternalPurchase(url: externalUrl, sku: sku) - } else { - let error = makePurchaseError(code: .featureNotSupported, productId: sku, message: "External purchase links require iOS 16.0 or later") - emitPurchaseError(error) - throw error - } - #else - let error = makePurchaseError(code: .featureNotSupported, productId: sku, message: "External purchase links are only supported on iOS") - emitPurchaseError(error) - throw error - #endif - } - let product = try await storeProduct(for: sku) let options = StoreKitTypesBridge.purchaseOptions(from: iosProps) @@ -638,6 +619,85 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { return [] } + // MARK: - External Purchase (iOS 18.2+) + + public func canPresentExternalPurchaseNoticeIOS() async throws -> Bool { + #if os(iOS) + if #available(iOS 18.2, *) { + return await ExternalPurchase.canPresent + } else { + return false + } + #else + return false + #endif + } + + public func presentExternalPurchaseNoticeSheetIOS() async throws -> ExternalPurchaseNoticeResultIOS { + #if os(iOS) + if #available(iOS 18.2, *) { + guard await ExternalPurchase.canPresent else { + return ExternalPurchaseNoticeResultIOS( + error: "External purchase notice sheet is not available", + result: .dismissed + ) + } + + do { + let result = try await ExternalPurchase.presentNoticeSheet() + switch result { + case .continuedWithExternalPurchaseToken(_): + return ExternalPurchaseNoticeResultIOS(error: nil, result: .continue) + @unknown default: + return ExternalPurchaseNoticeResultIOS( + error: "User dismissed notice sheet", + result: .dismissed + ) + } + } catch { + return ExternalPurchaseNoticeResultIOS( + error: error.localizedDescription, + result: .dismissed + ) + } + } else { + throw makePurchaseError( + code: .featureNotSupported, + message: "External purchase notice sheet requires iOS 18.2 or later" + ) + } + #else + throw makePurchaseError(code: .featureNotSupported) + #endif + } + + public func presentExternalPurchaseLinkIOS(_ url: String) async throws -> ExternalPurchaseLinkResultIOS { + #if canImport(UIKit) + guard let customLink = URL(string: url) else { + return ExternalPurchaseLinkResultIOS( + error: "Invalid URL", + success: false + ) + } + + return await MainActor.run { + if UIApplication.shared.canOpenURL(customLink) { + UIApplication.shared.open(customLink, options: [:]) { success in + // Completion handler - link opened + } + return ExternalPurchaseLinkResultIOS(error: nil, success: true) + } else { + return ExternalPurchaseLinkResultIOS( + error: "Cannot open URL", + success: false + ) + } + } + #else + throw makePurchaseError(code: .featureNotSupported) + #endif + } + // MARK: - Event Listener Registration public func purchaseUpdatedListener(_ listener: @escaping PurchaseUpdatedListener) -> Subscription { @@ -669,36 +729,6 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { // MARK: - Private Helpers - #if os(iOS) - @available(iOS 16.0, *) - private func handleExternalPurchase(url: String, sku: String) async throws -> RequestPurchaseResult? { - guard let externalUrl = URL(string: url) else { - let error = makePurchaseError(code: .purchaseError, productId: sku, message: "Invalid external purchase URL") - emitPurchaseError(error) - throw error - } - - // Open the external URL using UIApplication - let canOpen = await MainActor.run { - UIApplication.shared.canOpenURL(externalUrl) - } - - guard canOpen else { - let error = makePurchaseError(code: .purchaseError, productId: sku, message: "Cannot open external purchase URL") - emitPurchaseError(error) - throw error - } - - await MainActor.run { - UIApplication.shared.open(externalUrl, options: [:], completionHandler: nil) - } - - // Return nil as the purchase is handled externally - // The actual purchase completion should be handled by the external website - return nil - } - #endif - private func ensureConnection() async throws { if await state.isInitialized == false { _ = try await initConnection() @@ -765,7 +795,6 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { return RequestPurchaseIosProps( andDangerouslyFinishTransactionAutomatically: ios.andDangerouslyFinishTransactionAutomatically, appAccountToken: ios.appAccountToken, - externalPurchaseUrl: ios.externalPurchaseUrl, quantity: ios.quantity, sku: ios.sku, withOffer: ios.withOffer diff --git a/openiap-versions.json b/openiap-versions.json index ea782a9..f6b168b 100644 --- a/openiap-versions.json +++ b/openiap-versions.json @@ -1,4 +1,4 @@ { "apple": "1.2.8", - "gql": "1.0.11" + "gql": "1.0.12" } From 74cc4dd7e048dcb537740f70aeb4f16210d7537b Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 4 Oct 2025 01:25:07 +0900 Subject: [PATCH 2/2] chore: generate types --- Sources/Models/Types.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Models/Types.swift b/Sources/Models/Types.swift index 4217407..f79889d 100644 --- a/Sources/Models/Types.swift +++ b/Sources/Models/Types.swift @@ -69,6 +69,7 @@ public enum IapEvent: String, Codable, CaseIterable { case purchaseUpdated = "purchase-updated" case purchaseError = "purchase-error" case promotedProductIos = "promoted-product-ios" + case userChoiceBillingAndroid = "user-choice-billing-android" } public enum IapPlatform: String, Codable, CaseIterable {