@stripe/stripe-react-native 0.65.1 → 0.67.0

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.
Files changed (107) hide show
  1. package/android/detekt/detekt-baseline.xml +0 -10
  2. package/android/gradle.properties +1 -1
  3. package/android/src/main/java/com/reactnativestripesdk/CardFieldView.kt +51 -57
  4. package/android/src/main/java/com/reactnativestripesdk/CurrencySelectorElementEvent.kt +26 -0
  5. package/android/src/main/java/com/reactnativestripesdk/CurrencySelectorElementView.kt +129 -16
  6. package/android/src/main/java/com/reactnativestripesdk/CurrencySelectorElementViewManager.kt +17 -0
  7. package/android/src/main/java/com/reactnativestripesdk/EventEmitterCompat.kt +4 -0
  8. package/android/src/main/java/com/reactnativestripesdk/PaymentSheetManager.kt +8 -19
  9. package/android/src/main/java/com/reactnativestripesdk/StripeAbstractComposeView.kt +1 -0
  10. package/android/src/main/java/com/reactnativestripesdk/StripeSdkModule.kt +89 -31
  11. package/android/src/main/java/com/reactnativestripesdk/StripeSdkPackage.kt +1 -0
  12. package/android/src/main/java/com/reactnativestripesdk/customersheet/CustomerSheetManager.kt +2 -16
  13. package/android/src/main/java/com/reactnativestripesdk/utils/CheckoutMappers.kt +59 -33
  14. package/android/src/main/java/com/reactnativestripesdk/utils/DefaultActivityLifecycleCallbacks.kt +41 -0
  15. package/android/src/main/java/com/reactnativestripesdk/utils/Errors.kt +11 -6
  16. package/android/src/oldarch/java/com/reactnativestripesdk/NativeOnrampSdkModuleSpec.java +17 -3
  17. package/android/src/oldarch/java/com/reactnativestripesdk/NativeStripeSdkModuleSpec.java +2 -2
  18. package/android/src/onramp/java/com/reactnativestripesdk/OnrampErrors.kt +145 -0
  19. package/android/src/onramp/java/com/reactnativestripesdk/OnrampMappers.kt +125 -0
  20. package/android/src/onramp/java/com/reactnativestripesdk/OnrampSdkModule.kt +144 -22
  21. package/android/src/test/java/com/reactnativestripesdk/DrawableLoadingTest.kt +6 -2
  22. package/android/src/test/java/com/reactnativestripesdk/OnrampErrorsTest.kt +168 -0
  23. package/android/src/test/java/com/reactnativestripesdk/mappers/OnrampMappersTest.kt +107 -0
  24. package/android/src/test/java/com/reactnativestripesdk/utils/CheckoutMappersTest.kt +39 -30
  25. package/android/src/test/java/com/reactnativestripesdk/utils/CheckoutTestFixtures.java +8 -0
  26. package/ios/CurrencySelectorElementManager.m +7 -0
  27. package/ios/CurrencySelectorElementView.swift +142 -7
  28. package/ios/EmbeddedPaymentElementView.swift +1 -1
  29. package/ios/Mappers+Checkout.swift +251 -107
  30. package/ios/Mappers.swift +97 -7
  31. package/ios/NewArch/StripeCurrencySelectorElementComponentView.mm +14 -0
  32. package/ios/OldArch/StripeSdkEventEmitterCompat.h +1 -0
  33. package/ios/OldArch/StripeSdkEventEmitterCompat.m +6 -0
  34. package/ios/OnrampErrors.swift +132 -0
  35. package/ios/PaymentOptionDisplayData+ReactNative.swift +1 -1
  36. package/ios/PaymentSheetAppearance.swift +1 -1
  37. package/ios/StripeOnrampSdk.mm +19 -0
  38. package/ios/StripeSdk.mm +14 -14
  39. package/ios/StripeSdkEmitter.swift +1 -0
  40. package/ios/StripeSdkImpl+Checkout.swift +66 -31
  41. package/ios/StripeSdkImpl+CustomerSheet.swift +1 -1
  42. package/ios/StripeSdkImpl+Embedded.swift +1 -1
  43. package/ios/StripeSdkImpl+PaymentSheet.swift +1 -1
  44. package/ios/StripeSdkImpl.swift +116 -24
  45. package/lib/commonjs/components/PaymentMethodMessagingElement.js +1 -1
  46. package/lib/commonjs/components/PaymentMethodMessagingElement.js.map +1 -1
  47. package/lib/commonjs/components/StripeProvider.js +1 -1
  48. package/lib/commonjs/components/StripeProvider.js.map +1 -1
  49. package/lib/commonjs/hooks/useCheckout.js +1 -1
  50. package/lib/commonjs/hooks/useCheckout.js.map +1 -1
  51. package/lib/commonjs/hooks/useOnramp.js +1 -1
  52. package/lib/commonjs/hooks/useOnramp.js.map +1 -1
  53. package/lib/commonjs/internal/stripeConfig.js +2 -0
  54. package/lib/commonjs/internal/stripeConfig.js.map +1 -0
  55. package/lib/commonjs/specs/NativeOnrampSdkModule.js.map +1 -1
  56. package/lib/commonjs/specs/NativeStripeSdkModule.js.map +1 -1
  57. package/lib/commonjs/types/Errors.js +1 -1
  58. package/lib/commonjs/types/Errors.js.map +1 -1
  59. package/lib/commonjs/types/Onramp.js +1 -1
  60. package/lib/commonjs/types/Onramp.js.map +1 -1
  61. package/lib/module/components/PaymentMethodMessagingElement.js +1 -1
  62. package/lib/module/components/PaymentMethodMessagingElement.js.map +1 -1
  63. package/lib/module/components/StripeProvider.js +1 -1
  64. package/lib/module/components/StripeProvider.js.map +1 -1
  65. package/lib/module/hooks/useCheckout.js +1 -1
  66. package/lib/module/hooks/useCheckout.js.map +1 -1
  67. package/lib/module/hooks/useOnramp.js +1 -1
  68. package/lib/module/hooks/useOnramp.js.map +1 -1
  69. package/lib/module/internal/stripeConfig.js +2 -0
  70. package/lib/module/internal/stripeConfig.js.map +1 -0
  71. package/lib/module/specs/NativeOnrampSdkModule.js.map +1 -1
  72. package/lib/module/specs/NativeStripeSdkModule.js.map +1 -1
  73. package/lib/module/types/Errors.js +1 -1
  74. package/lib/module/types/Errors.js.map +1 -1
  75. package/lib/module/types/Onramp.js +1 -1
  76. package/lib/module/types/Onramp.js.map +1 -1
  77. package/lib/typescript/src/components/PaymentMethodMessagingElement.d.ts +1 -1
  78. package/lib/typescript/src/components/PaymentMethodMessagingElement.d.ts.map +1 -1
  79. package/lib/typescript/src/components/StripeProvider.d.ts.map +1 -1
  80. package/lib/typescript/src/hooks/useCheckout.d.ts.map +1 -1
  81. package/lib/typescript/src/hooks/useOnramp.d.ts +32 -10
  82. package/lib/typescript/src/hooks/useOnramp.d.ts.map +1 -1
  83. package/lib/typescript/src/internal/stripeConfig.d.ts +3 -0
  84. package/lib/typescript/src/internal/stripeConfig.d.ts.map +1 -0
  85. package/lib/typescript/src/specs/NativeOnrampSdkModule.d.ts +3 -0
  86. package/lib/typescript/src/specs/NativeOnrampSdkModule.d.ts.map +1 -1
  87. package/lib/typescript/src/specs/NativeStripeSdkModule.d.ts +2 -2
  88. package/lib/typescript/src/specs/NativeStripeSdkModule.d.ts.map +1 -1
  89. package/lib/typescript/src/types/Checkout.d.ts +266 -92
  90. package/lib/typescript/src/types/Checkout.d.ts.map +1 -1
  91. package/lib/typescript/src/types/Errors.d.ts +0 -5
  92. package/lib/typescript/src/types/Errors.d.ts.map +1 -1
  93. package/lib/typescript/src/types/Onramp.d.ts +168 -9
  94. package/lib/typescript/src/types/Onramp.d.ts.map +1 -1
  95. package/package.json +3 -2
  96. package/src/components/PaymentMethodMessagingElement.tsx +54 -52
  97. package/src/components/StripeProvider.tsx +2 -0
  98. package/src/hooks/useCheckout.tsx +20 -13
  99. package/src/hooks/useOnramp.tsx +139 -12
  100. package/src/internal/stripeConfig.ts +9 -0
  101. package/src/specs/NativeOnrampSdkModule.ts +5 -0
  102. package/src/specs/NativeStripeSdkModule.ts +6 -5
  103. package/src/types/Checkout.ts +289 -93
  104. package/src/types/Errors.ts +0 -6
  105. package/src/types/Onramp.ts +192 -9
  106. package/stripe-react-native.podspec +3 -3
  107. package/ios/CurrencySelectorElementManager.swift +0 -12
@@ -41,9 +41,13 @@ class DrawableLoadingTest {
41
41
  canvas.drawColor(Color.RED)
42
42
  }
43
43
 
44
- override fun setAlpha(alpha: Int) {}
44
+ override fun setAlpha(alpha: Int) {
45
+ // NO-OP
46
+ }
45
47
 
46
- override fun setColorFilter(colorFilter: ColorFilter?) {}
48
+ override fun setColorFilter(colorFilter: ColorFilter?) {
49
+ // NO-OP
50
+ }
47
51
 
48
52
  override fun getOpacity(): Int = PixelFormat.OPAQUE
49
53
  }
@@ -0,0 +1,168 @@
1
+ @file:OptIn(ExperimentalCryptoOnramp::class)
2
+
3
+ package com.reactnativestripesdk
4
+
5
+ import com.stripe.android.core.StripeError
6
+ import com.stripe.android.core.exception.APIException
7
+ import com.stripe.android.crypto.onramp.ExperimentalCryptoOnramp
8
+ import com.stripe.android.crypto.onramp.exception.APIErrorContext
9
+ import com.stripe.android.crypto.onramp.exception.SDKVersion
10
+ import org.junit.Assert.assertEquals
11
+ import org.junit.Assert.assertNotNull
12
+ import org.junit.Assert.assertTrue
13
+ import org.junit.Test
14
+ import org.junit.runner.RunWith
15
+ import org.robolectric.RobolectricTestRunner
16
+
17
+ @RunWith(RobolectricTestRunner::class)
18
+ class OnrampErrorsTest {
19
+ @Test
20
+ fun createOnrampFailedError_withAppAttestationException_preservesOnrampDiagnostics() {
21
+ val error =
22
+ createOnrampException(
23
+ className = "com.stripe.android.crypto.onramp.exception.AppAttestationException",
24
+ reason = "android_package_name_mismatch",
25
+ operation = "configure",
26
+ appPackageName = "com.example.app",
27
+ mode = "test",
28
+ sdkVersions =
29
+ listOf(
30
+ SDKVersion(name = "stripe-android", version = "23.9.1"),
31
+ SDKVersion(name = "stripe-react-native", version = "0.66.0"),
32
+ ),
33
+ userMessage = "App attestation failed.",
34
+ apiErrorCode = "link_failed_to_attest_request",
35
+ apiErrorType = "api_error",
36
+ apiErrorMessage = "Attestation request could not be verified.",
37
+ apiUserMessage = "App attestation failed.",
38
+ docUrl = "https://docs.stripe.com/errors/app-attestation",
39
+ requestId = "req_attestation",
40
+ )
41
+
42
+ val details = createOnrampFailedError(error).getMap("error")
43
+
44
+ assertNotNull(details)
45
+ assertEquals("Failed", details!!.getString("code"))
46
+ assertEquals("AppAttestationError", details.getString("onrampErrorType"))
47
+ assertEquals("App attestation failed.", details.getString("message"))
48
+ assertEquals("App attestation failed.", details.getString("localizedMessage"))
49
+ assertEquals("App attestation failed.", details.getString("userMessage"))
50
+ assertEquals("link_failed_to_attest_request", details.getString("stripeErrorCode"))
51
+ assertEquals("link_failed_to_attest_request", details.getString("apiErrorCode"))
52
+ assertEquals("api_error", details.getString("type"))
53
+ assertEquals("api_error", details.getString("apiErrorType"))
54
+ assertEquals("android_package_name_mismatch", details.getString("reason"))
55
+ assertEquals("configure", details.getString("operation"))
56
+ assertEquals("com.example.app", details.getString("appPackageName"))
57
+ assertEquals("test", details.getString("mode"))
58
+ val sdkVersions = details.getArray("sdkVersions")
59
+ assertNotNull(sdkVersions)
60
+ val stripeAndroidVersion = sdkVersions!!.getMap(0)
61
+ assertEquals("stripe-android", stripeAndroidVersion!!.getString("name"))
62
+ assertEquals("23.9.1", stripeAndroidVersion.getString("version"))
63
+ val reactNativeVersion = sdkVersions.getMap(1)
64
+ assertEquals("stripe-react-native", reactNativeVersion!!.getString("name"))
65
+ assertEquals("0.66.0", reactNativeVersion.getString("version"))
66
+ assertEquals("req_attestation", details.getString("requestId"))
67
+ assertEquals(
68
+ "Attestation request could not be verified.",
69
+ details.getString("apiErrorMessage"),
70
+ )
71
+ assertEquals("App attestation failed.", details.getString("apiUserMessage"))
72
+ assertEquals(
73
+ "https://docs.stripe.com/errors/app-attestation",
74
+ details.getString("docUrl"),
75
+ )
76
+ assertTrue(details.getString("developerMessage")!!.contains("operation: configure"))
77
+ assertTrue(details.getString("developerMessage")!!.contains("request_id: req_attestation"))
78
+ }
79
+
80
+ @Test
81
+ fun createOnrampFailedError_withUncategorizedApiError_preservesStripeCodes() {
82
+ val error =
83
+ createOnrampException(
84
+ className = "com.stripe.android.crypto.onramp.exception.UncategorizedApiErrorException",
85
+ reason = "consumer_session_expired",
86
+ operation = "authorize",
87
+ appPackageName = "com.example.app",
88
+ mode = "live",
89
+ sdkVersions = listOf(SDKVersion(name = "stripe-android", version = "23.9.1")),
90
+ userMessage = "Session expired. Please sign in again.",
91
+ apiErrorCode = "consumer_session_expired",
92
+ apiErrorType = "authentication_error",
93
+ apiErrorMessage = "The consumer session has expired.",
94
+ apiUserMessage = "Session expired. Please sign in again.",
95
+ docUrl = "https://docs.stripe.com/error-codes/consumer-session-expired",
96
+ requestId = "req_auth",
97
+ )
98
+
99
+ val details = createOnrampFailedError(error).getMap("error")
100
+
101
+ assertNotNull(details)
102
+ assertEquals("UncategorizedApiError", details!!.getString("onrampErrorType"))
103
+ assertEquals("consumer_session_expired", details.getString("stripeErrorCode"))
104
+ assertEquals("authentication_error", details.getString("type"))
105
+ assertEquals("Session expired. Please sign in again.", details.getString("message"))
106
+ assertEquals("Session expired. Please sign in again.", details.getString("userMessage"))
107
+ assertEquals("authorize", details.getString("operation"))
108
+ assertEquals("req_auth", details.getString("requestId"))
109
+ assertTrue(details.getString("developerMessage")!!.contains("Code: consumer_session_expired"))
110
+ }
111
+
112
+ private fun createOnrampException(
113
+ className: String,
114
+ reason: String,
115
+ operation: String,
116
+ appPackageName: String,
117
+ mode: String,
118
+ sdkVersions: List<SDKVersion>,
119
+ userMessage: String,
120
+ apiErrorCode: String,
121
+ apiErrorType: String,
122
+ apiErrorMessage: String,
123
+ apiUserMessage: String,
124
+ docUrl: String,
125
+ requestId: String,
126
+ ): Exception {
127
+ val stripeError =
128
+ StripeError(
129
+ type = apiErrorType,
130
+ message = apiErrorMessage,
131
+ code = apiErrorCode,
132
+ docUrl = docUrl,
133
+ )
134
+ val cause = APIException(stripeError = stripeError, requestId = requestId)
135
+ val context =
136
+ APIErrorContext(
137
+ reason = reason,
138
+ operation = operation,
139
+ appPackageName = appPackageName,
140
+ mode = mode,
141
+ apiErrorCode = apiErrorCode,
142
+ apiErrorType = apiErrorType,
143
+ apiErrorMessage = apiErrorMessage,
144
+ apiUserMessage = apiUserMessage,
145
+ docUrl = docUrl,
146
+ underlyingError = cause,
147
+ )
148
+
149
+ // The exception classes in the `com.stripe.android.crypto.onramp.exception` package
150
+ // don't have public constructors, so we use reflection to create instances for testing.
151
+ val constructor =
152
+ Class
153
+ .forName(className)
154
+ .getDeclaredConstructor(
155
+ APIErrorContext::class.java,
156
+ List::class.java,
157
+ String::class.java,
158
+ ).apply {
159
+ isAccessible = true
160
+ }
161
+
162
+ return constructor.newInstance(
163
+ context,
164
+ sdkVersions,
165
+ userMessage,
166
+ ) as Exception
167
+ }
168
+ }
@@ -4,12 +4,17 @@ package com.reactnativestripesdk.mappers
4
4
 
5
5
  import android.annotation.SuppressLint
6
6
  import androidx.compose.ui.graphics.toArgb
7
+ import com.reactnativestripesdk.ComplianceIdentifierFieldException
8
+ import com.reactnativestripesdk.InvalidIdentifiersArrayException
7
9
  import com.reactnativestripesdk.mapAppearance
8
10
  import com.reactnativestripesdk.mapConfig
9
11
  import com.reactnativestripesdk.mapFromKycInfo
10
12
  import com.reactnativestripesdk.mapGooglePayConfig
11
13
  import com.reactnativestripesdk.mapPaymentDetailsType
14
+ import com.reactnativestripesdk.mapToComplianceIdentifiers
15
+ import com.reactnativestripesdk.utils.readableArrayOf
12
16
  import com.reactnativestripesdk.utils.readableMapOf
17
+ import com.stripe.android.core.model.CountryCode
13
18
  import com.stripe.android.crypto.onramp.ExperimentalCryptoOnramp
14
19
  import com.stripe.android.crypto.onramp.model.KycInfo
15
20
  import com.stripe.android.crypto.onramp.model.PaymentMethodDisplayData
@@ -22,12 +27,14 @@ import org.junit.Assert.assertEquals
22
27
  import org.junit.Assert.assertFalse
23
28
  import org.junit.Assert.assertNotNull
24
29
  import org.junit.Assert.assertNull
30
+ import org.junit.Assert.assertThrows
25
31
  import org.junit.Assert.assertTrue
26
32
  import org.junit.Test
27
33
  import org.junit.runner.RunWith
28
34
  import org.robolectric.RobolectricTestRunner
29
35
 
30
36
  @SuppressLint("RestrictedApi")
37
+ @Suppress("LargeClass")
31
38
  @RunWith(RobolectricTestRunner::class)
32
39
  class OnrampMappersTest {
33
40
  @Test
@@ -460,6 +467,13 @@ class OnrampMappersTest {
460
467
  state = "CA",
461
468
  ),
462
469
  dateOfBirth = DateOfBirth(day = 15, month = 6, year = 1990),
470
+ birthCountry = CountryCode.create("FR"),
471
+ birthCity = "Paris",
472
+ nationalities =
473
+ listOf(
474
+ CountryCode.create("FR"),
475
+ CountryCode.create("DE"),
476
+ ),
463
477
  )
464
478
  val result = mapFromKycInfo(kycInfo)
465
479
 
@@ -481,6 +495,14 @@ class OnrampMappersTest {
481
495
  assertEquals(15, dob!!.getInt("day"))
482
496
  assertEquals(6, dob.getInt("month"))
483
497
  assertEquals(1990, dob.getInt("year"))
498
+
499
+ assertEquals("FR", result.getString("birthCountry"))
500
+ assertEquals("Paris", result.getString("birthCity"))
501
+
502
+ val nationalities = result.getArray("nationalities")
503
+ assertNotNull(nationalities)
504
+ assertEquals("FR", nationalities!!.getString(0))
505
+ assertEquals("DE", nationalities.getString(1))
484
506
  }
485
507
 
486
508
  @Test
@@ -492,6 +514,9 @@ class OnrampMappersTest {
492
514
  idNumber = null,
493
515
  address = null,
494
516
  dateOfBirth = null,
517
+ birthCountry = null,
518
+ birthCity = null,
519
+ nationalities = null,
495
520
  )
496
521
  val result = mapFromKycInfo(kycInfo)
497
522
 
@@ -500,6 +525,9 @@ class OnrampMappersTest {
500
525
  assertFalse(result.hasKey("idNumber"))
501
526
  assertFalse(result.hasKey("address"))
502
527
  assertFalse(result.hasKey("dateOfBirth"))
528
+ assertFalse(result.hasKey("birthCountry"))
529
+ assertFalse(result.hasKey("birthCity"))
530
+ assertFalse(result.hasKey("nationalities"))
503
531
  }
504
532
 
505
533
  @Test
@@ -511,6 +539,9 @@ class OnrampMappersTest {
511
539
  idNumber = null,
512
540
  address = null,
513
541
  dateOfBirth = DateOfBirth(day = 1, month = 1, year = 2000),
542
+ birthCountry = CountryCode.create("IT"),
543
+ birthCity = "Rome",
544
+ nationalities = listOf(CountryCode.create("IT")),
514
545
  )
515
546
  val result = mapFromKycInfo(kycInfo)
516
547
 
@@ -524,6 +555,9 @@ class OnrampMappersTest {
524
555
  assertEquals(1, dob!!.getInt("day"))
525
556
  assertEquals(1, dob.getInt("month"))
526
557
  assertEquals(2000, dob.getInt("year"))
558
+ assertEquals("IT", result.getString("birthCountry"))
559
+ assertEquals("Rome", result.getString("birthCity"))
560
+ assertEquals("IT", result.getArray("nationalities")!!.getString(0))
527
561
  }
528
562
 
529
563
  @Test
@@ -543,6 +577,9 @@ class OnrampMappersTest {
543
577
  state = null,
544
578
  ),
545
579
  dateOfBirth = null,
580
+ birthCountry = null,
581
+ birthCity = null,
582
+ nationalities = null,
546
583
  )
547
584
  val result = mapFromKycInfo(kycInfo)
548
585
 
@@ -555,4 +592,74 @@ class OnrampMappersTest {
555
592
  assertFalse(address.hasKey("postalCode"))
556
593
  assertFalse(address.hasKey("state"))
557
594
  }
595
+
596
+ @Test
597
+ fun mapToComplianceIdentifiers_ValidIdentifiers_ReturnsIdentifiers() {
598
+ val identifiers =
599
+ readableArrayOf(
600
+ readableMapOf(
601
+ "type" to " AT_STN ",
602
+ "value" to " 123456789 ",
603
+ ),
604
+ readableMapOf(
605
+ "type" to "DE_STN",
606
+ "value" to "987654321",
607
+ ),
608
+ )
609
+
610
+ val result = mapToComplianceIdentifiers(identifiers)
611
+
612
+ assertEquals(2, result.size)
613
+ }
614
+
615
+ @Test
616
+ fun mapToComplianceIdentifiers_BlankType_ThrowsFieldException() {
617
+ val identifiers =
618
+ readableArrayOf(
619
+ readableMapOf(
620
+ "type" to " ",
621
+ "value" to "123456789",
622
+ ),
623
+ )
624
+
625
+ val error =
626
+ assertThrows(ComplianceIdentifierFieldException::class.java) {
627
+ mapToComplianceIdentifiers(identifiers)
628
+ }
629
+
630
+ assertEquals("Invalid format for field: type", error.message)
631
+ }
632
+
633
+ @Test
634
+ fun mapToComplianceIdentifiers_BlankValue_ThrowsFieldException() {
635
+ val identifiers =
636
+ readableArrayOf(
637
+ readableMapOf(
638
+ "type" to "AT_STN",
639
+ "value" to " ",
640
+ ),
641
+ )
642
+
643
+ val error =
644
+ assertThrows(ComplianceIdentifierFieldException::class.java) {
645
+ mapToComplianceIdentifiers(identifiers)
646
+ }
647
+
648
+ assertEquals("Invalid format for field: value", error.message)
649
+ }
650
+
651
+ @Test
652
+ fun mapToComplianceIdentifiers_NonMapEntry_ThrowsInvalidArrayException() {
653
+ val identifiers = readableArrayOf("AT_STN")
654
+
655
+ val error =
656
+ assertThrows(InvalidIdentifiersArrayException::class.java) {
657
+ mapToComplianceIdentifiers(identifiers)
658
+ }
659
+
660
+ assertEquals(
661
+ "Unexpected format of identifiers array. Expected dictionaries with String keys.",
662
+ error.message,
663
+ )
664
+ }
558
665
  }
@@ -4,6 +4,7 @@ import com.stripe.android.paymentelement.CheckoutSessionPreview
4
4
  import org.junit.Assert.assertEquals
5
5
  import org.junit.Assert.assertFalse
6
6
  import org.junit.Assert.assertNotNull
7
+ import org.junit.Assert.assertNull
7
8
  import org.junit.Test
8
9
  import org.junit.runner.RunWith
9
10
  import org.robolectric.RobolectricTestRunner
@@ -12,7 +13,7 @@ import org.robolectric.RobolectricTestRunner
12
13
  @OptIn(CheckoutSessionPreview::class)
13
14
  class CheckoutMappersTest {
14
15
  @Test
15
- fun mapFromCheckoutState_mapsLoadedSessionAndCurrentAndroidPlaceholders() {
16
+ fun mapFromCheckoutState_mapsLoadedSession() {
16
17
  val mapped =
17
18
  mapFromCheckoutState(
18
19
  isLoading = false,
@@ -24,27 +25,27 @@ class CheckoutMappersTest {
24
25
  val session = mapped.getMap("session")
25
26
  assertNotNull(session)
26
27
  assertEquals("cs_test_123", session!!.getString("id"))
27
- assertEquals("unknown", session.getString("status"))
28
- assertEquals("unknown", session.getString("paymentStatus"))
29
28
  assertFalse(session.getBoolean("livemode"))
30
29
  assertEquals("usd", session.getString("currency"))
31
- assertFalse(session.hasKey("customerId"))
32
- assertFalse(session.hasKey("customerEmail"))
33
- assertFalse(session.hasKey("billingAddress"))
34
- assertFalse(session.hasKey("shippingAddress"))
35
-
36
- val totals = session.getMap("totals")
37
- assertNotNull(totals)
38
- assertEquals(5000.0, totals!!.getDouble("subtotal"), 0.0)
39
- assertEquals(4044.0, totals.getDouble("total"), 0.0)
40
- assertEquals(4044.0, totals.getDouble("due"), 0.0)
41
- assertEquals(0.0, totals.getDouble("discount"), 0.0)
42
- assertEquals(500.0, totals.getDouble("shipping"), 0.0)
43
- assertEquals(294.0, totals.getDouble("tax"), 0.0)
44
-
45
- val discounts = session.getArray("discounts")
46
- assertNotNull(discounts)
47
- assertEquals(0, discounts!!.size())
30
+
31
+ val status = session.getMap("status")
32
+ assertNotNull(status)
33
+ assertEquals("open", status!!.getString("type"))
34
+
35
+ val tax = session.getMap("tax")
36
+ assertNotNull(tax)
37
+ assertEquals("ready", tax!!.getString("status"))
38
+
39
+ val total = session.getMap("total")
40
+ assertNotNull(total)
41
+ val subtotal = total!!.getMap("subtotal")
42
+ assertNotNull(subtotal)
43
+ assertEquals(5000.0, subtotal!!.getDouble("minorUnitsAmount"), 0.0)
44
+ assertEquals("", subtotal.getString("amount"))
45
+
46
+ val grandTotal = total.getMap("total")
47
+ assertNotNull(grandTotal)
48
+ assertEquals(4044.0, grandTotal!!.getDouble("minorUnitsAmount"), 0.0)
48
49
 
49
50
  val lineItems = session.getArray("lineItems")
50
51
  assertNotNull(lineItems)
@@ -52,19 +53,27 @@ class CheckoutMappersTest {
52
53
  assertEquals("li_item1", lineItems.getMap(0)?.getString("id"))
53
54
  assertEquals("Llama Figure", lineItems.getMap(0)?.getString("name"))
54
55
  assertEquals(2, lineItems.getMap(0)?.getInt("quantity"))
55
- assertEquals(0.0, lineItems.getMap(0)?.getDouble("unitAmount") ?: -1.0, 0.0)
56
- assertEquals("usd", lineItems.getMap(0)?.getString("currency"))
57
- assertEquals(2499.0, lineItems.getMap(1)?.getDouble("unitAmount") ?: -1.0, 0.0)
56
+ assertFalse(lineItems.getMap(0)!!.hasKey("unitAmount"))
57
+
58
+ val item2UnitAmount = lineItems.getMap(1)?.getMap("unitAmount")
59
+ assertNotNull(item2UnitAmount)
60
+ assertEquals(2499.0, item2UnitAmount!!.getDouble("minorUnitsAmount"), 0.0)
58
61
 
59
62
  val shippingOptions = session.getArray("shippingOptions")
60
63
  assertNotNull(shippingOptions)
61
64
  assertEquals(2, shippingOptions!!.size())
62
65
  assertEquals("shr_standard", shippingOptions.getMap(0)?.getString("id"))
63
- assertEquals("Standard Shipping", shippingOptions.getMap(0)?.getString("displayName"))
64
- assertEquals(500.0, shippingOptions.getMap(0)?.getDouble("amount") ?: -1.0, 0.0)
65
- assertEquals("usd", shippingOptions.getMap(0)?.getString("currency"))
66
- assertFalse(shippingOptions.getMap(0)?.hasKey("deliveryEstimate") ?: true)
67
- assertEquals("1-2 business days", shippingOptions.getMap(1)?.getString("deliveryEstimate"))
66
+ val shippingAmount = shippingOptions.getMap(0)?.getMap("amount")
67
+ assertNotNull(shippingAmount)
68
+ assertEquals(500.0, shippingAmount!!.getDouble("minorUnitsAmount"), 0.0)
69
+
70
+ val discountAmounts = session.getArray("discountAmounts")
71
+ assertNotNull(discountAmounts)
72
+ assertEquals(0, discountAmounts!!.size())
73
+
74
+ val currencyOptions = session.getArray("currencyOptions")
75
+ assertNotNull(currencyOptions)
76
+ assertEquals(0, currencyOptions!!.size())
68
77
  }
69
78
 
70
79
  @Test
@@ -81,9 +90,9 @@ class CheckoutMappersTest {
81
90
  assertNotNull(session)
82
91
  assertEquals("cs_test_blank_currency", session!!.getString("id"))
83
92
  assertFalse(session.hasKey("currency"))
84
- assertFalse(session.hasKey("totals"))
93
+ assertNull(session.getMap("total"))
85
94
  assertEquals(0, session.getArray("lineItems")!!.size())
86
95
  assertEquals(0, session.getArray("shippingOptions")!!.size())
87
- assertEquals(0, session.getArray("discounts")!!.size())
96
+ assertEquals(0, session.getArray("discountAmounts")!!.size())
88
97
  }
89
98
  }
@@ -25,7 +25,11 @@ public final class CheckoutTestFixtures {
25
25
 
26
26
  return new CheckoutSession(
27
27
  "cs_test_123",
28
+ CheckoutSession.Status.Open,
29
+ false,
28
30
  "usd",
31
+ null,
32
+ new CheckoutSession.Tax(CheckoutSession.Tax.Status.Ready),
29
33
  totals,
30
34
  Arrays.asList(
31
35
  new CheckoutSession.LineItem("li_item1", "Llama Figure", 2, null, 1998L, 1998L),
@@ -40,8 +44,12 @@ public final class CheckoutTestFixtures {
40
44
  public static CheckoutSession blankCurrencySession() {
41
45
  return new CheckoutSession(
42
46
  "cs_test_blank_currency",
47
+ CheckoutSession.Status.Open,
48
+ false,
43
49
  "",
44
50
  null,
51
+ new CheckoutSession.Tax(CheckoutSession.Tax.Status.Ready),
52
+ null,
45
53
  Collections.<CheckoutSession.LineItem>emptyList(),
46
54
  Collections.<CheckoutSession.ShippingRate>emptyList(),
47
55
  (CurrencySelectorOptions) null);
@@ -1,3 +1,10 @@
1
+ //
2
+ // CurrencySelectorElementManager.m
3
+ // stripe-react-native
4
+ //
5
+ // Created by Nick Porter on 5/7/26.
6
+ //
7
+
1
8
  #import <Foundation/Foundation.h>
2
9
  #import <React/RCTBridgeModule.h>
3
10
  #import <React/RCTViewManager.h>
@@ -1,24 +1,159 @@
1
+ //
2
+ // CurrencySelectorElementView.swift
3
+ // stripe-react-native
4
+ //
5
+ // Created by Nick Porter on 5/7/26.
6
+ //
7
+
8
+ import Combine
1
9
  import Foundation
10
+ @_spi(ReactNativeSDK) import StripePaymentSheet
2
11
  import UIKit
3
12
 
4
- /// Minimal stub so the JS-side `CurrencySelectorElement` codegen mapping
5
- /// resolves at app launch. The real implementation (hosting
6
- /// `Checkout.CurrencySelectorView`, height reporting, session observation)
7
- /// lives on the full-feature branch and will replace this once that lands.
13
+ @objc(StripeCurrencySelectorElementManager)
14
+ class StripeCurrencySelectorElementManager: RCTViewManager {
15
+ override static func requiresMainQueueSetup() -> Bool {
16
+ return true
17
+ }
18
+
19
+ override func view() -> UIView! {
20
+ return StripeCurrencySelectorElementContainerView(frame: .zero)
21
+ }
22
+ }
23
+
8
24
  @objc(StripeCurrencySelectorElementContainerView)
9
25
  public class StripeCurrencySelectorElementContainerView: UIView {
10
- @objc public var sessionKey: String?
11
26
 
12
- @objc public var disabled: Bool = false
27
+ // MARK: - Bridge-exposed Props
28
+
29
+ @objc public var sessionKey: String? {
30
+ didSet {
31
+ guard sessionKey != oldValue else { return }
32
+ attachToSessionIfPossible()
33
+ }
34
+ }
13
35
 
36
+ @objc public var disabled: Bool = false {
37
+ didSet {
38
+ selectorView?.isEnabled = !disabled
39
+ }
40
+ }
41
+
42
+ /// Fired whenever the inner selector's intrinsic height changes so RN
43
+ /// can match it. Session-state changes flow through the central
44
+ /// `checkoutSessionDidChangeState` event on `StripeSdk` (see
45
+ /// `CheckoutStateBridge`), so this view does not need to push state.
14
46
  @objc public var onHeightChange: RCTDirectEventBlock?
15
47
 
16
- public override init(frame: CGRect) {
48
+ // MARK: - Private Properties
49
+
50
+ private var selectorView: Checkout.CurrencySelectorView?
51
+ private var sessionCancellable: AnyCancellable?
52
+ private var lastReportedHeight: CGFloat = -1
53
+
54
+ // MARK: - Init
55
+
56
+ override public init(frame: CGRect) {
17
57
  super.init(frame: frame)
58
+ backgroundColor = .clear
59
+ clipsToBounds = true
18
60
  }
19
61
 
20
62
  @available(*, unavailable)
21
63
  required init?(coder _: NSCoder) {
22
64
  fatalError("init(coder:) has not been implemented")
23
65
  }
66
+
67
+ // MARK: - Layout
68
+
69
+ public override func layoutSubviews() {
70
+ super.layoutSubviews()
71
+ reportHeightIfNeeded()
72
+ }
73
+
74
+ // MARK: - Setup
75
+
76
+ private func attachToSessionIfPossible() {
77
+ Task { @MainActor in
78
+ guard let key = self.sessionKey,
79
+ let checkout = StripeSdkImpl.shared.checkoutInstances[key] else {
80
+ self.detach()
81
+ return
82
+ }
83
+
84
+ // Already showing a selector — leave it in place. Re-creating
85
+ // would interrupt the inner view's loading animation and reset
86
+ // its enabled state mid-API-call.
87
+ if self.selectorView != nil {
88
+ return
89
+ }
90
+
91
+ self.installSelectorView(for: checkout)
92
+ self.subscribeForLayoutUpdates(on: checkout)
93
+ }
94
+ }
95
+
96
+ /// We don't push state to JS here — `CheckoutStateBridge` already does
97
+ /// that for the whole session. This subscription only nudges Auto Layout
98
+ /// so the caption-text changes after a currency selection are reflected
99
+ /// in our reported height back to RN.
100
+ private func subscribeForLayoutUpdates(on checkout: Checkout) {
101
+ sessionCancellable = checkout.$state
102
+ .dropFirst()
103
+ .receive(on: DispatchQueue.main)
104
+ .sink { [weak self] _ in
105
+ guard let self else { return }
106
+ self.setNeedsLayout()
107
+ self.layoutIfNeeded()
108
+ self.reportHeightIfNeeded()
109
+ }
110
+ }
111
+
112
+ private func installSelectorView(for checkout: Checkout) {
113
+ let view = Checkout.CurrencySelectorView(checkout: checkout)
114
+ view.translatesAutoresizingMaskIntoConstraints = false
115
+ view.isEnabled = !disabled
116
+ addSubview(view)
117
+
118
+ NSLayoutConstraint.activate([
119
+ view.topAnchor.constraint(equalTo: topAnchor),
120
+ view.leadingAnchor.constraint(equalTo: leadingAnchor),
121
+ view.trailingAnchor.constraint(equalTo: trailingAnchor),
122
+ view.bottomAnchor.constraint(equalTo: bottomAnchor),
123
+ ])
124
+
125
+ selectorView = view
126
+
127
+ // Force an initial height report so RN can lay us out at zero
128
+ // when Adaptive Pricing is unavailable.
129
+ setNeedsLayout()
130
+ layoutIfNeeded()
131
+ }
132
+
133
+ private func detach() {
134
+ sessionCancellable?.cancel()
135
+ sessionCancellable = nil
136
+ selectorView?.removeFromSuperview()
137
+ selectorView = nil
138
+ lastReportedHeight = -1
139
+ }
140
+
141
+ // MARK: - Events
142
+
143
+ private func reportHeightIfNeeded() {
144
+ let desiredHeight: CGFloat
145
+ if let selectorView, !selectorView.isHidden {
146
+ desiredHeight = selectorView.systemLayoutSizeFitting(
147
+ CGSize(width: bounds.width, height: UIView.layoutFittingCompressedSize.height),
148
+ withHorizontalFittingPriority: .required,
149
+ verticalFittingPriority: .fittingSizeLevel
150
+ ).height
151
+ } else {
152
+ desiredHeight = 0
153
+ }
154
+
155
+ guard desiredHeight != lastReportedHeight else { return }
156
+ lastReportedHeight = desiredHeight
157
+ onHeightChange?(["height": desiredHeight])
158
+ }
24
159
  }