rampkit-expo-dev 0.0.65 → 0.0.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,6 +18,8 @@ interface RampKitNativeModule {
18
18
  getNotificationPermissions(): Promise<NotificationPermissionResult>;
19
19
  startTransactionObserver(appId: string): Promise<void>;
20
20
  stopTransactionObserver(): Promise<void>;
21
+ trackPurchaseCompleted(productId: string, transactionId?: string, originalTransactionId?: string): Promise<void>;
22
+ trackPurchaseFromProduct(productId: string): Promise<void>;
21
23
  }
22
24
  export interface NativeDeviceInfo {
23
25
  appUserId: string;
@@ -162,4 +164,21 @@ export declare const TransactionObserver: {
162
164
  * Stop listening for purchase transactions
163
165
  */
164
166
  stop(): Promise<void>;
167
+ /**
168
+ * Manually track a purchase completion
169
+ * Use this when Superwall/RevenueCat reports a purchase but the automatic
170
+ * observer doesn't catch it (they finish transactions before we see them)
171
+ *
172
+ * @param productId - The product ID (e.g., "com.app.yearly")
173
+ * @param transactionId - Optional transaction ID if available
174
+ * @param originalTransactionId - Optional original transaction ID (for renewals)
175
+ */
176
+ trackPurchase(productId: string, transactionId?: string, originalTransactionId?: string): Promise<void>;
177
+ /**
178
+ * Track a purchase by looking up the product's latest transaction
179
+ * Use this when you only have the productId (common with Superwall)
180
+ *
181
+ * @param productId - The product ID to look up and track
182
+ */
183
+ trackPurchaseByProductId(productId: string): Promise<void>;
165
184
  };
@@ -62,6 +62,8 @@ function createFallbackModule() {
62
62
  },
63
63
  async startTransactionObserver(_appId) { },
64
64
  async stopTransactionObserver() { },
65
+ async trackPurchaseCompleted(_productId, _transactionId, _originalTransactionId) { },
66
+ async trackPurchaseFromProduct(_productId) { },
65
67
  };
66
68
  }
67
69
  function generateFallbackUserId() {
@@ -285,4 +287,39 @@ exports.TransactionObserver = {
285
287
  console.warn("[RampKit] Failed to stop transaction observer:", e);
286
288
  }
287
289
  },
290
+ /**
291
+ * Manually track a purchase completion
292
+ * Use this when Superwall/RevenueCat reports a purchase but the automatic
293
+ * observer doesn't catch it (they finish transactions before we see them)
294
+ *
295
+ * @param productId - The product ID (e.g., "com.app.yearly")
296
+ * @param transactionId - Optional transaction ID if available
297
+ * @param originalTransactionId - Optional original transaction ID (for renewals)
298
+ */
299
+ async trackPurchase(productId, transactionId, originalTransactionId) {
300
+ try {
301
+ console.log("[RampKit] Manually tracking purchase:", productId);
302
+ await RampKitNativeModule.trackPurchaseCompleted(productId, transactionId, originalTransactionId);
303
+ console.log("[RampKit] Purchase tracked successfully:", productId);
304
+ }
305
+ catch (e) {
306
+ console.warn("[RampKit] Failed to track purchase:", e);
307
+ }
308
+ },
309
+ /**
310
+ * Track a purchase by looking up the product's latest transaction
311
+ * Use this when you only have the productId (common with Superwall)
312
+ *
313
+ * @param productId - The product ID to look up and track
314
+ */
315
+ async trackPurchaseByProductId(productId) {
316
+ try {
317
+ console.log("[RampKit] Looking up and tracking purchase for:", productId);
318
+ await RampKitNativeModule.trackPurchaseFromProduct(productId);
319
+ console.log("[RampKit] Purchase lookup and tracking complete:", productId);
320
+ }
321
+ catch (e) {
322
+ console.warn("[RampKit] Failed to track purchase by product:", e);
323
+ }
324
+ },
288
325
  };
@@ -9,14 +9,23 @@ public class RampKitModule: Module {
9
9
  private let installDateKey = "rk_install_date"
10
10
  private let launchCountKey = "rk_launch_count"
11
11
  private let lastLaunchKey = "rk_last_launch"
12
-
12
+
13
13
  // Transaction observer task
14
14
  private var transactionObserverTask: Task<Void, Never>?
15
15
  private var appId: String?
16
16
  private var userId: String?
17
-
17
+
18
+ // Queue for transactions received before SDK is configured
19
+ private var pendingTransactions: [Any] = []
20
+ private var isConfigured = false
21
+
18
22
  public func definition() -> ModuleDefinition {
19
23
  Name("RampKit")
24
+
25
+ // Start transaction observer immediately when module loads
26
+ OnCreate {
27
+ self.startTransactionObserverEarly()
28
+ }
20
29
 
21
30
  // ============================================================================
22
31
  // Device Info
@@ -89,14 +98,30 @@ public class RampKitModule: Module {
89
98
  // ============================================================================
90
99
  // Transaction Observer (StoreKit 2)
91
100
  // ============================================================================
92
-
101
+
93
102
  AsyncFunction("startTransactionObserver") { (appId: String) in
94
103
  self.startTransactionObserver(appId: appId)
95
104
  }
96
-
105
+
97
106
  AsyncFunction("stopTransactionObserver") { () in
98
107
  self.stopTransactionObserver()
99
108
  }
109
+
110
+ // ============================================================================
111
+ // Manual Purchase Tracking (Fallback for Superwall/RevenueCat)
112
+ // ============================================================================
113
+
114
+ AsyncFunction("trackPurchaseCompleted") { (productId: String, transactionId: String?, originalTransactionId: String?) in
115
+ await self.trackPurchaseCompletedManually(
116
+ productId: productId,
117
+ transactionId: transactionId,
118
+ originalTransactionId: originalTransactionId
119
+ )
120
+ }
121
+
122
+ AsyncFunction("trackPurchaseFromProduct") { (productId: String) in
123
+ await self.trackPurchaseFromProductId(productId: productId)
124
+ }
100
125
  }
101
126
 
102
127
  // MARK: - Device Info Collection
@@ -386,61 +411,136 @@ public class RampKitModule: Module {
386
411
  }
387
412
 
388
413
  // MARK: - StoreKit 2 Transaction Observer
389
-
414
+
415
+ /// Called on module init - starts listening BEFORE JavaScript configures us
416
+ private func startTransactionObserverEarly() {
417
+ // Get userId early so we have it ready
418
+ self.userId = getOrCreateUserId()
419
+
420
+ guard #available(iOS 15.0, *) else {
421
+ print("[RampKit] ⚠️ Transaction observer requires iOS 15+")
422
+ return
423
+ }
424
+
425
+ // Don't restart if already running
426
+ guard transactionObserverTask == nil else {
427
+ print("[RampKit] Transaction observer already running")
428
+ return
429
+ }
430
+
431
+ print("[RampKit] 🚀 Starting transaction observer early (module init)...")
432
+
433
+ transactionObserverTask = Task {
434
+ print("[RampKit] 👂 Transaction.updates loop starting...")
435
+ for await result in Transaction.updates {
436
+ print("[RampKit] 🎉 RECEIVED TRANSACTION UPDATE!")
437
+ await self.handleTransactionUpdate(result)
438
+ }
439
+ print("[RampKit] ⚠️ Transaction.updates loop exited (unexpected)")
440
+ }
441
+
442
+ // Also check unfinished transactions
443
+ Task {
444
+ await self.handleUnfinishedTransactions()
445
+ }
446
+ }
447
+
448
+ /// Called from JavaScript - sets appId and processes any pending transactions
390
449
  private func startTransactionObserver(appId: String) {
391
450
  self.appId = appId
392
451
  self.userId = getOrCreateUserId()
393
-
394
- // Cancel existing observer if any
395
- transactionObserverTask?.cancel()
396
-
397
- // Start listening for transactions (iOS 15+)
452
+ self.isConfigured = true
453
+
454
+ print("[RampKit] ✅ Transaction observer configured with appId: \(appId)")
455
+
456
+ // Process any transactions that arrived before we were configured
398
457
  if #available(iOS 15.0, *) {
399
- transactionObserverTask = Task {
400
- await self.listenForTransactions()
458
+ Task {
459
+ await self.processPendingTransactions()
401
460
  }
402
-
403
- // Also check for any unfinished transactions
461
+
462
+ // Debug: check current entitlements
404
463
  Task {
405
- await self.handleUnfinishedTransactions()
464
+ await self.debugCurrentEntitlements()
406
465
  }
407
466
  }
408
-
409
- print("[RampKit] Transaction observer started")
467
+
468
+ // Ensure observer is running (in case OnCreate didn't fire)
469
+ if transactionObserverTask == nil {
470
+ startTransactionObserverEarly()
471
+ }
410
472
  }
411
-
412
- private func stopTransactionObserver() {
413
- transactionObserverTask?.cancel()
414
- transactionObserverTask = nil
415
- print("[RampKit] Transaction observer stopped")
473
+
474
+ @available(iOS 15.0, *)
475
+ private func handleTransactionUpdate(_ result: VerificationResult<Transaction>) async {
476
+ do {
477
+ let transaction = try self.checkVerified(result)
478
+ print("[RampKit] ✅ Transaction verified: \(transaction.productID)")
479
+
480
+ if self.isConfigured, let _ = self.appId {
481
+ // We're configured, process immediately
482
+ await self.handleTransaction(transaction)
483
+ } else {
484
+ // Not configured yet, queue it
485
+ print("[RampKit] 📦 Queueing transaction (SDK not configured yet): \(transaction.productID)")
486
+ self.pendingTransactions.append(transaction)
487
+ }
488
+
489
+ // Always finish the transaction
490
+ await transaction.finish()
491
+ print("[RampKit] 🏁 Transaction finished: \(transaction.productID)")
492
+ } catch {
493
+ print("[RampKit] ❌ Transaction verification failed: \(error)")
494
+ }
416
495
  }
417
-
496
+
418
497
  @available(iOS 15.0, *)
419
- private func listenForTransactions() async {
420
- // Listen for transaction updates
421
- for await result in Transaction.updates {
422
- do {
423
- let transaction = try self.checkVerified(result)
498
+ private func processPendingTransactions() async {
499
+ guard !pendingTransactions.isEmpty else { return }
500
+
501
+ print("[RampKit] 📤 Processing \(pendingTransactions.count) pending transaction(s)...")
502
+
503
+ for item in pendingTransactions {
504
+ if let transaction = item as? Transaction {
424
505
  await self.handleTransaction(transaction)
425
- await transaction.finish()
426
- } catch {
427
- print("[RampKit] Transaction verification failed: \(error)")
428
506
  }
429
507
  }
508
+
509
+ pendingTransactions.removeAll()
510
+ print("[RampKit] ✅ All pending transactions processed")
430
511
  }
431
-
512
+
513
+ @available(iOS 15.0, *)
514
+ private func debugCurrentEntitlements() async {
515
+ print("[RampKit] 🔍 Checking current entitlements...")
516
+ var count = 0
517
+ for await result in Transaction.currentEntitlements {
518
+ if case .verified(let transaction) = result {
519
+ count += 1
520
+ print("[RampKit] 📦 Current entitlement: \(transaction.productID), originalID: \(transaction.originalID), id: \(transaction.id)")
521
+ }
522
+ }
523
+ print("[RampKit] 🔍 Found \(count) current entitlements")
524
+ }
525
+
526
+ private func stopTransactionObserver() {
527
+ transactionObserverTask?.cancel()
528
+ transactionObserverTask = nil
529
+ isConfigured = false
530
+ pendingTransactions.removeAll()
531
+ print("[RampKit] Transaction observer stopped")
532
+ }
533
+
432
534
  @available(iOS 15.0, *)
433
535
  private func handleUnfinishedTransactions() async {
434
- // Handle any transactions that weren't finished
536
+ print("[RampKit] 🔍 Checking for unfinished transactions...")
537
+ var count = 0
435
538
  for await result in Transaction.unfinished {
436
- do {
437
- let transaction = try self.checkVerified(result)
438
- await self.handleTransaction(transaction)
439
- await transaction.finish()
440
- } catch {
441
- print("[RampKit] Unfinished transaction verification failed: \(error)")
442
- }
539
+ count += 1
540
+ print("[RampKit] 📦 Found unfinished transaction #\(count)")
541
+ await self.handleTransactionUpdate(result)
443
542
  }
543
+ print("[RampKit] 🔍 Finished checking unfinished transactions. Found: \(count)")
444
544
  }
445
545
 
446
546
  @available(iOS 15.0, *)
@@ -457,36 +557,38 @@ public class RampKitModule: Module {
457
557
 
458
558
  @available(iOS 15.0, *)
459
559
  private func handleTransaction(_ transaction: Transaction) async {
460
- guard let appId = self.appId, let userId = self.userId else { return }
560
+ print("[RampKit] 🔄 handleTransaction called for: \(transaction.productID)")
561
+ print("[RampKit] - id: \(transaction.id)")
562
+ print("[RampKit] - originalID: \(transaction.originalID)")
563
+ print("[RampKit] - purchaseDate: \(transaction.purchaseDate)")
564
+
565
+ guard let appId = self.appId, let userId = self.userId else {
566
+ print("[RampKit] ❌ handleTransaction failed: appId=\(self.appId ?? "nil"), userId=\(self.userId ?? "nil")")
567
+ return
568
+ }
461
569
 
462
570
  let formatter = ISO8601DateFormatter()
463
571
  formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
464
572
 
465
- // Determine event type based on transaction (matching iOS SDK logic)
466
- let eventName: String
467
- var properties: [String: Any] = [:]
573
+ // Skip renewals - backend gets these from App Store Server-to-Server notifications
574
+ if transaction.originalID != transaction.id {
575
+ print("[RampKit] ⏭️ Transaction skipped (renewal): \(transaction.productID)")
576
+ return
577
+ }
468
578
 
469
- // Check revocation first (subscription_canceled)
579
+ // Skip revocations/cancellations - backend gets these from S2S notifications
470
580
  if transaction.revocationDate != nil {
471
- eventName = "subscription_canceled"
472
- if let reason = transaction.revocationReason {
473
- properties["revocationReason"] = reason == .developerIssue ? "developerIssue" : "other"
474
- }
475
- properties["revocationDate"] = transaction.revocationDate.map { formatter.string(from: $0) }
476
- }
477
- // Check if this is a renewal (originalID != id)
478
- else if transaction.originalID != transaction.id {
479
- eventName = "subscription_renewed"
480
- }
481
- // Check if it's a trial or intro offer
482
- else if let offerType = transaction.offerType, offerType == .introductory {
483
- eventName = "trial_started"
484
- }
485
- // Default to purchase_completed
486
- else {
487
- eventName = "purchase_completed"
581
+ print("[RampKit] ⏭️ Transaction skipped (revoked): \(transaction.productID)")
582
+ return
488
583
  }
489
584
 
585
+ print("[RampKit] ✅ Transaction is a new purchase, will send event")
586
+
587
+ // All new purchases (including trials) are tracked as purchase_completed
588
+ // The isTrial and offerType properties indicate trial status
589
+ let eventName = "purchase_completed"
590
+ var properties: [String: Any] = [:]
591
+
490
592
  // Build properties (matching iOS SDK PurchaseEventDetails)
491
593
  properties["productId"] = transaction.productID
492
594
  properties["transactionId"] = String(transaction.id)
@@ -631,8 +733,8 @@ public class RampKitModule: Module {
631
733
  var request = URLRequest(url: url)
632
734
  request.httpMethod = "POST"
633
735
  request.setValue("application/json", forHTTPHeaderField: "Content-Type")
634
- request.setValue("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV1c3RsenV2am1vY2h4a3hhdGZ4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzU1NjQ0NjYsImV4cCI6MjA1MTE0MDQ2Nn0.5cNrph5LHmssNo39UKpULkC9n4OD5n6gsnTEQV-gwQk", forHTTPHeaderField: "apikey")
635
- request.setValue("Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV1c3RsenV2am1vY2h4a3hhdGZ4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzU1NjQ0NjYsImV4cCI6MjA1MTE0MDQ2Nn0.5cNrph5LHmssNo39UKpULkC9n4OD5n6gsnTEQV-gwQk", forHTTPHeaderField: "Authorization")
736
+ request.setValue("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV1c3RsenV2am1vY2h4a3hhdGZ4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjIxMDM2NTUsImV4cCI6MjA3NzY3OTY1NX0.d5XsIMGnia4n9Pou0IidipyyEfSlwpXFoeDBufMOEwE", forHTTPHeaderField: "apikey")
737
+ request.setValue("Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV1c3RsenV2am1vY2h4a3hhdGZ4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjIxMDM2NTUsImV4cCI6MjA3NzY3OTY1NX0.d5XsIMGnia4n9Pou0IidipyyEfSlwpXFoeDBufMOEwE", forHTTPHeaderField: "Authorization")
636
738
 
637
739
  do {
638
740
  request.httpBody = try JSONSerialization.data(withJSONObject: event)
@@ -644,7 +746,104 @@ public class RampKitModule: Module {
644
746
  print("[RampKit] Failed to send purchase event: \(error)")
645
747
  }
646
748
  }
647
-
749
+
750
+ // MARK: - Manual Purchase Tracking (Fallback for Superwall/RevenueCat)
751
+
752
+ /// Track a purchase manually when you have the transaction IDs
753
+ /// Use this when Superwall or RevenueCat provides the transaction info
754
+ @available(iOS 15.0, *)
755
+ private func trackPurchaseCompletedManually(
756
+ productId: String,
757
+ transactionId: String?,
758
+ originalTransactionId: String?
759
+ ) async {
760
+ print("[RampKit] 📲 Manual purchase tracking called for: \(productId)")
761
+
762
+ guard let appId = self.appId, let userId = self.userId else {
763
+ print("[RampKit] ❌ Manual tracking failed: SDK not initialized (appId or userId missing)")
764
+ return
765
+ }
766
+
767
+ let formatter = ISO8601DateFormatter()
768
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
769
+
770
+ var properties: [String: Any] = [
771
+ "productId": productId,
772
+ "purchaseDate": formatter.string(from: Date()),
773
+ "source": "manual" // Mark as manually tracked
774
+ ]
775
+
776
+ // Add transaction IDs if available
777
+ if let transactionId = transactionId {
778
+ properties["transactionId"] = transactionId
779
+ }
780
+ if let originalTransactionId = originalTransactionId {
781
+ properties["originalTransactionId"] = originalTransactionId
782
+ } else if let transactionId = transactionId {
783
+ // Use transactionId as originalTransactionId if not provided (first purchase)
784
+ properties["originalTransactionId"] = transactionId
785
+ }
786
+
787
+ // Try to get product info from StoreKit
788
+ if let product = await getProduct(for: productId) {
789
+ properties["amount"] = product.price
790
+ properties["currency"] = product.priceFormatStyle.currencyCode
791
+ properties["priceFormatted"] = product.displayPrice
792
+ properties["productType"] = mapProductType(product.type)
793
+
794
+ if let subscription = product.subscription {
795
+ properties["subscriptionPeriod"] = formatSubscriptionPeriod(subscription.subscriptionPeriod)
796
+ properties["subscriptionGroupId"] = subscription.subscriptionGroupID
797
+ }
798
+ }
799
+
800
+ // Get storefront
801
+ if let storefront = await Storefront.current {
802
+ properties["storefront"] = storefront.countryCode
803
+ }
804
+
805
+ print("[RampKit] 📤 Sending manual purchase_completed event...")
806
+ await sendPurchaseEvent(
807
+ appId: appId,
808
+ userId: userId,
809
+ eventName: "purchase_completed",
810
+ properties: properties
811
+ )
812
+ }
813
+
814
+ /// Track a purchase by looking up the latest transaction for a product
815
+ /// Use this when you only know the productId (Superwall doesn't always provide transaction IDs)
816
+ @available(iOS 15.0, *)
817
+ private func trackPurchaseFromProductId(productId: String) async {
818
+ print("[RampKit] 🔍 Looking up transaction for product: \(productId)")
819
+
820
+ // Try to find the latest transaction for this product
821
+ var latestTransaction: Transaction?
822
+
823
+ for await result in Transaction.currentEntitlements {
824
+ if case .verified(let transaction) = result {
825
+ if transaction.productID == productId {
826
+ if latestTransaction == nil || transaction.purchaseDate > latestTransaction!.purchaseDate {
827
+ latestTransaction = transaction
828
+ }
829
+ }
830
+ }
831
+ }
832
+
833
+ if let transaction = latestTransaction {
834
+ print("[RampKit] ✅ Found transaction for product: \(productId)")
835
+ await handleTransaction(transaction)
836
+ } else {
837
+ print("[RampKit] ⚠️ No transaction found for product, tracking manually: \(productId)")
838
+ // Fall back to manual tracking without transaction IDs
839
+ await trackPurchaseCompletedManually(
840
+ productId: productId,
841
+ transactionId: nil,
842
+ originalTransactionId: nil
843
+ )
844
+ }
845
+ }
846
+
648
847
  // MARK: - Device Helpers
649
848
 
650
849
  private func getDeviceModelIdentifier() -> String {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rampkit-expo-dev",
3
- "version": "0.0.65",
3
+ "version": "0.0.67",
4
4
  "description": "The Expo SDK for RampKit. Build, test, and personalize app onboardings with instant updates.",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",