Skip to content
This repository was archived by the owner on Oct 16, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Example/OpenIapExample/Screens/AllProductsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ struct AllProductsView: View {
}

HStack {
Text(product.displayPrice ?? "--")
Text(product.displayPrice)
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.blue)
Expand Down
111 changes: 62 additions & 49 deletions Example/OpenIapExample/Screens/AlternativeBillingScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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"
)
}
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
}
}
}

Expand Down
69 changes: 60 additions & 9 deletions Sources/Models/Types.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,19 @@ 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"
case promotedProductIos = "promoted-product-ios"
case userChoiceBillingAndroid = "user-choice-billing-android"
}

public enum IapPlatform: String, Codable, CaseIterable {
Expand Down Expand Up @@ -217,6 +226,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]?)
Expand Down Expand Up @@ -469,6 +494,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
Expand Down Expand Up @@ -635,8 +669,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
Expand All @@ -647,14 +679,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
Expand Down Expand Up @@ -784,23 +814,19 @@ 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?

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
Expand Down Expand Up @@ -1155,6 +1181,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
Expand All @@ -1178,6 +1208,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
Expand Down Expand Up @@ -1222,6 +1254,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
Expand All @@ -1239,6 +1274,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
Expand All @@ -1259,6 +1296,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?
Expand All @@ -1279,6 +1318,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,
Expand All @@ -1298,6 +1339,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
Expand All @@ -1310,6 +1353,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]
Expand All @@ -1329,6 +1373,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?
Expand All @@ -1348,6 +1393,7 @@ public struct QueryHandlers {
public var validateReceiptIOS: QueryValidateReceiptIOSHandler?

public init(
canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? = nil,
currentEntitlementIOS: QueryCurrentEntitlementIOSHandler? = nil,
fetchProducts: QueryFetchProductsHandler? = nil,
getActiveSubscriptions: QueryGetActiveSubscriptionsHandler? = nil,
Expand All @@ -1366,6 +1412,7 @@ public struct QueryHandlers {
subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? = nil,
validateReceiptIOS: QueryValidateReceiptIOSHandler? = nil
) {
self.canPresentExternalPurchaseNoticeIOS = canPresentExternalPurchaseNoticeIOS
self.currentEntitlementIOS = currentEntitlementIOS
self.fetchProducts = fetchProducts
self.getActiveSubscriptions = getActiveSubscriptions
Expand All @@ -1391,19 +1438,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
}
}
Loading