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

Commit 873b7e1

Browse files
authored
feat: alternative billing support (#15)
1 parent 3ac9cef commit 873b7e1

File tree

6 files changed

+126
-11
lines changed

6 files changed

+126
-11
lines changed

Example/Martie.xcodeproj/project.pbxproj

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@
77
objects = {
88

99
/* Begin PBXBuildFile section */
10+
3472D7D42E8CFCB100C0714E /* AlternativeBillingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3472D7D32E8CFCB100C0714E /* AlternativeBillingScreen.swift */; };
11+
C0APV0022D20000000000001 /* AllProductsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0APV0012D20000000000001 /* AllProductsView.swift */; };
1012
C0E1F5E92C8F1A9400123456 /* OpenIapExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E1F5E82C8F1A9400123456 /* OpenIapExampleApp.swift */; };
1113
C0E1F5EB2C8F1A9400123456 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E1F5EA2C8F1A9400123456 /* ContentView.swift */; };
1214
C0E1F5ED2C8F1A9500123456 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C0E1F5EC2C8F1A9500123456 /* Assets.xcassets */; };
1315
C0E1F5F02C8F1A9500123456 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C0E1F5EF2C8F1A9500123456 /* Preview Assets.xcassets */; };
1416
C0E1F5F82C8F1AA300123456 /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E1F5F72C8F1AA300123456 /* HomeScreen.swift */; };
15-
C0APV0022D20000000000001 /* AllProductsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0APV0012D20000000000001 /* AllProductsView.swift */; };
1617
C0E1F5FA2C8F1AAB00123456 /* PurchaseFlowScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E1F5F92C8F1AAB00123456 /* PurchaseFlowScreen.swift */; };
1718
C0E1F5FC2C8F1AB000123456 /* SubscriptionFlowScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E1F5FB2C8F1AB000123456 /* SubscriptionFlowScreen.swift */; };
1819
C0E1F5FE2C8F1AB500123456 /* AvailablePurchasesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E1F5FD2C8F1AB500123456 /* AvailablePurchasesScreen.swift */; };
@@ -39,6 +40,8 @@
3940
/* End PBXBuildFile section */
4041

4142
/* Begin PBXFileReference section */
43+
3472D7D32E8CFCB100C0714E /* AlternativeBillingScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlternativeBillingScreen.swift; sourceTree = "<group>"; };
44+
C0APV0012D20000000000001 /* AllProductsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllProductsView.swift; sourceTree = "<group>"; };
4245
C0E1F5E52C8F1A9400123456 /* OpenIapExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenIapExample.app; sourceTree = BUILT_PRODUCTS_DIR; };
4346
C0E1F5E82C8F1A9400123456 /* OpenIapExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenIapExampleApp.swift; sourceTree = "<group>"; };
4447
C0E1F5EA2C8F1A9400123456 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
@@ -49,7 +52,6 @@
4952
C0E1F5FB2C8F1AB000123456 /* SubscriptionFlowScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionFlowScreen.swift; sourceTree = "<group>"; };
5053
C0E1F5FD2C8F1AB500123456 /* AvailablePurchasesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvailablePurchasesScreen.swift; sourceTree = "<group>"; };
5154
C0E1F5FF2C8F1ABA00123456 /* OfferCodeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfferCodeScreen.swift; sourceTree = "<group>"; };
52-
C0APV0012D20000000000001 /* AllProductsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllProductsView.swift; sourceTree = "<group>"; };
5355
C0E1F6032C8F1AC500123456 /* AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppColors.swift; sourceTree = "<group>"; };
5456
C0IAC0002D10000000000001 /* IapCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IapCompat.swift; sourceTree = "<group>"; };
5557
C0UI10012D00000000000001 /* FeatureCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureCard.swift; sourceTree = "<group>"; };
@@ -126,6 +128,7 @@
126128
C0UI20012D00000000000001 /* uis */,
127129
C0E1F5F72C8F1AA300123456 /* HomeScreen.swift */,
128130
C0APV0012D20000000000001 /* AllProductsView.swift */,
131+
3472D7D32E8CFCB100C0714E /* AlternativeBillingScreen.swift */,
129132
C0E1F5F92C8F1AAB00123456 /* PurchaseFlowScreen.swift */,
130133
C0E1F5FB2C8F1AB000123456 /* SubscriptionFlowScreen.swift */,
131134
C0E1F5FD2C8F1AB500123456 /* AvailablePurchasesScreen.swift */,
@@ -263,6 +266,7 @@
263266
C0UI00022D00000000000002 /* LoadingCard.swift in Sources */,
264267
C0UI00032D00000000000003 /* EmptyStateCard.swift in Sources */,
265268
C0UI00042D00000000000004 /* SectionHeaderView.swift in Sources */,
269+
3472D7D42E8CFCB100C0714E /* AlternativeBillingScreen.swift in Sources */,
266270
C0UI00052D00000000000005 /* ProductListCard.swift in Sources */,
267271
C0UI00062D00000000000006 /* ProductGridCard.swift in Sources */,
268272
C0UI00072D00000000000007 /* ActivePurchaseCard.swift in Sources */,

Example/OpenIapExample/Screens/HomeScreen.swift

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,30 +61,40 @@ struct HomeScreen: View {
6161
color: Color.teal,
6262
destination: AnyView(PurchaseFlowScreen())
6363
)
64-
64+
6565
FeatureCard(
66-
title: "Subscription\nFlow",
66+
title: "Subscription\nFlow",
6767
subtitle: "Test subscriptions",
6868
icon: "repeat.circle.fill",
6969
color: AppColors.secondary,
7070
destination: AnyView(SubscriptionFlowScreen())
7171
)
72-
72+
7373
FeatureCard(
7474
title: "My\nPurchases",
7575
subtitle: "View your purchases",
7676
icon: "list.bullet.rectangle.fill",
7777
color: AppColors.success,
7878
destination: AnyView(AvailablePurchasesScreen())
7979
)
80-
80+
8181
FeatureCard(
8282
title: "Offer\nCode",
8383
subtitle: "Redeem promotional codes",
8484
icon: "gift.fill",
8585
color: AppColors.warning,
8686
destination: AnyView(OfferCodeScreen())
8787
)
88+
89+
if #available(iOS 16.0, *) {
90+
FeatureCard(
91+
title: "Alternative\nBilling",
92+
subtitle: "External purchase links",
93+
icon: "link.circle.fill",
94+
color: Color.purple,
95+
destination: AnyView(AlternativeBillingScreen())
96+
)
97+
}
8898
}
8999
.padding(.horizontal)
90100

Sources/Models/Types.swift

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -526,19 +526,39 @@ public struct RequestPurchaseIosProps: Codable {
526526
public var andDangerouslyFinishTransactionAutomatically: Bool?
527527
/// App account token for user tracking
528528
public var appAccountToken: String?
529+
/// External purchase URL for alternative billing on iOS
530+
public var externalPurchaseUrlOnIOS: String?
529531
/// Purchase quantity
530532
public var quantity: Int?
531533
/// Product SKU
532534
public var sku: String
533535
/// Discount offer to apply
534536
public var withOffer: DiscountOfferInputIOS?
537+
538+
public init(
539+
andDangerouslyFinishTransactionAutomatically: Bool? = nil,
540+
appAccountToken: String? = nil,
541+
externalPurchaseUrlOnIOS: String? = nil,
542+
quantity: Int? = nil,
543+
sku: String,
544+
withOffer: DiscountOfferInputIOS? = nil
545+
) {
546+
self.andDangerouslyFinishTransactionAutomatically = andDangerouslyFinishTransactionAutomatically
547+
self.appAccountToken = appAccountToken
548+
self.externalPurchaseUrlOnIOS = externalPurchaseUrlOnIOS
549+
self.quantity = quantity
550+
self.sku = sku
551+
self.withOffer = withOffer
552+
}
535553
}
536554

537555
public struct RequestPurchaseProps: Codable {
538556
public var request: Request
539557
public var type: ProductQueryType
558+
/// Enable alternative billing flow
559+
public var useAlternativeBilling: Bool?
540560

541-
public init(request: Request, type: ProductQueryType? = nil) {
561+
public init(request: Request, type: ProductQueryType? = nil, useAlternativeBilling: Bool? = nil) {
542562
switch request {
543563
case .purchase:
544564
let resolved = type ?? .inApp
@@ -550,17 +570,20 @@ public struct RequestPurchaseProps: Codable {
550570
self.type = resolved
551571
}
552572
self.request = request
573+
self.useAlternativeBilling = useAlternativeBilling
553574
}
554575

555576
private enum CodingKeys: String, CodingKey {
556577
case requestPurchase
557578
case requestSubscription
558579
case type
580+
case useAlternativeBilling
559581
}
560582

561583
public init(from decoder: Decoder) throws {
562584
let container = try decoder.container(keyedBy: CodingKeys.self)
563585
let decodedType = try container.decodeIfPresent(ProductQueryType.self, forKey: .type)
586+
self.useAlternativeBilling = try container.decodeIfPresent(Bool.self, forKey: .useAlternativeBilling)
564587
if let purchase = try container.decodeIfPresent(RequestPurchasePropsByPlatforms.self, forKey: .requestPurchase) {
565588
let finalType = decodedType ?? .inApp
566589
guard finalType == .inApp else {
@@ -604,6 +627,11 @@ public struct RequestPurchasePropsByPlatforms: Codable {
604627
public var android: RequestPurchaseAndroidProps?
605628
/// iOS-specific purchase parameters
606629
public var ios: RequestPurchaseIosProps?
630+
631+
public init(android: RequestPurchaseAndroidProps? = nil, ios: RequestPurchaseIosProps? = nil) {
632+
self.android = android
633+
self.ios = ios
634+
}
607635
}
608636

609637
public struct RequestSubscriptionAndroidProps: Codable {
@@ -626,16 +654,39 @@ public struct RequestSubscriptionAndroidProps: Codable {
626654
public struct RequestSubscriptionIosProps: Codable {
627655
public var andDangerouslyFinishTransactionAutomatically: Bool?
628656
public var appAccountToken: String?
657+
/// External purchase URL for alternative billing on iOS
658+
public var externalPurchaseUrlOnIOS: String?
629659
public var quantity: Int?
630660
public var sku: String
631661
public var withOffer: DiscountOfferInputIOS?
662+
663+
public init(
664+
andDangerouslyFinishTransactionAutomatically: Bool? = nil,
665+
appAccountToken: String? = nil,
666+
externalPurchaseUrlOnIOS: String? = nil,
667+
quantity: Int? = nil,
668+
sku: String,
669+
withOffer: DiscountOfferInputIOS? = nil
670+
) {
671+
self.andDangerouslyFinishTransactionAutomatically = andDangerouslyFinishTransactionAutomatically
672+
self.appAccountToken = appAccountToken
673+
self.externalPurchaseUrlOnIOS = externalPurchaseUrlOnIOS
674+
self.quantity = quantity
675+
self.sku = sku
676+
self.withOffer = withOffer
677+
}
632678
}
633679

634680
public struct RequestSubscriptionPropsByPlatforms: Codable {
635681
/// Android-specific subscription parameters
636682
public var android: RequestSubscriptionAndroidProps?
637683
/// iOS-specific subscription parameters
638684
public var ios: RequestSubscriptionIosProps?
685+
686+
public init(android: RequestSubscriptionAndroidProps? = nil, ios: RequestSubscriptionIosProps? = nil) {
687+
self.android = android
688+
self.ios = ios
689+
}
639690
}
640691

641692
// MARK: - Unions

Sources/OpenIapModule+ObjC.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -456,22 +456,22 @@ import StoreKit
456456
let dictionary = OpenIapSerialization.purchase(purchase)
457457
callback(dictionary as NSDictionary)
458458
}
459-
return subscription as! NSObject
459+
return subscription as NSObject
460460
}
461461

462462
@objc func addPurchaseErrorListener(_ callback: @escaping (NSDictionary) -> Void) -> NSObject {
463463
let subscription = purchaseErrorListener { error in
464464
let dictionary = OpenIapSerialization.encode(error)
465465
callback(dictionary as NSDictionary)
466466
}
467-
return subscription as! NSObject
467+
return subscription as NSObject
468468
}
469469

470470
@objc func addPromotedProductListener(_ callback: @escaping (String?) -> Void) -> NSObject {
471471
let subscription = promotedProductListenerIOS { sku in
472472
callback(sku)
473473
}
474-
return subscription as! NSObject
474+
return subscription as NSObject
475475
}
476476

477477
@objc func removeListener(_ subscription: NSObject) {

Sources/OpenIapModule.swift

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,25 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol {
162162
try await ensureConnection()
163163
let iosProps = try resolveIosPurchaseProps(from: params)
164164
let sku = iosProps.sku
165+
166+
// Check for alternative billing with external purchase URL
167+
if let externalUrl = iosProps.externalPurchaseUrlOnIOS,
168+
params.useAlternativeBilling == true {
169+
#if os(iOS)
170+
if #available(iOS 16.0, *) {
171+
return try await handleExternalPurchase(url: externalUrl, sku: sku)
172+
} else {
173+
let error = makePurchaseError(code: .featureNotSupported, productId: sku, message: "External purchase links require iOS 16.0 or later")
174+
emitPurchaseError(error)
175+
throw error
176+
}
177+
#else
178+
let error = makePurchaseError(code: .featureNotSupported, productId: sku, message: "External purchase links are only supported on iOS")
179+
emitPurchaseError(error)
180+
throw error
181+
#endif
182+
}
183+
165184
let product = try await storeProduct(for: sku)
166185
let options = StoreKitTypesBridge.purchaseOptions(from: iosProps)
167186

@@ -643,6 +662,36 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol {
643662

644663
// MARK: - Private Helpers
645664

665+
#if os(iOS)
666+
@available(iOS 16.0, *)
667+
private func handleExternalPurchase(url: String, sku: String) async throws -> RequestPurchaseResult? {
668+
guard let externalUrl = URL(string: url) else {
669+
let error = makePurchaseError(code: .purchaseError, productId: sku, message: "Invalid external purchase URL")
670+
emitPurchaseError(error)
671+
throw error
672+
}
673+
674+
// Open the external URL using UIApplication
675+
let canOpen = await MainActor.run {
676+
UIApplication.shared.canOpenURL(externalUrl)
677+
}
678+
679+
guard canOpen else {
680+
let error = makePurchaseError(code: .purchaseError, productId: sku, message: "Cannot open external purchase URL")
681+
emitPurchaseError(error)
682+
throw error
683+
}
684+
685+
await MainActor.run {
686+
UIApplication.shared.open(externalUrl, options: [:], completionHandler: nil)
687+
}
688+
689+
// Return nil as the purchase is handled externally
690+
// The actual purchase completion should be handled by the external website
691+
return nil
692+
}
693+
#endif
694+
646695
private func ensureConnection() async throws {
647696
if await state.isInitialized == false {
648697
_ = try await initConnection()
@@ -709,6 +758,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol {
709758
return RequestPurchaseIosProps(
710759
andDangerouslyFinishTransactionAutomatically: ios.andDangerouslyFinishTransactionAutomatically,
711760
appAccountToken: ios.appAccountToken,
761+
externalPurchaseUrlOnIOS: ios.externalPurchaseUrlOnIOS,
712762
quantity: ios.quantity,
713763
sku: ios.sku,
714764
withOffer: ios.withOffer

Sources/OpenIapVersion.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public struct OpenIapVersion {
1919
return version
2020
}
2121
// Fallback to hardcoded version
22-
return "1.2.5"
22+
return "1.0.10"
2323
}()
2424

2525
private static func loadVersionFromJSON() -> String? {

0 commit comments

Comments
 (0)