@onramper/onramper-react-native 1.2.2-1 → 1.3.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 (69) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/README.md +45 -3
  3. package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64/OnramperSDK.framework/Modules/OnramperSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  4. package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64/OnramperSDK.framework/Modules/OnramperSDK.swiftmodule/arm64-apple-ios.swiftinterface +107 -4
  5. package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64/OnramperSDK.framework/OnramperSDK +0 -0
  6. package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64/dSYMs/OnramperSDK.framework.dSYM/Contents/Resources/DWARF/OnramperSDK +0 -0
  7. package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64/dSYMs/OnramperSDK.framework.dSYM/Contents/Resources/Relocations/aarch64/OnramperSDK.yml +3560 -2684
  8. package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64_x86_64-simulator/OnramperSDK.framework/Modules/OnramperSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  9. package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64_x86_64-simulator/OnramperSDK.framework/Modules/OnramperSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +107 -4
  10. package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64_x86_64-simulator/OnramperSDK.framework/Modules/OnramperSDK.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  11. package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64_x86_64-simulator/OnramperSDK.framework/Modules/OnramperSDK.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +107 -4
  12. package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64_x86_64-simulator/OnramperSDK.framework/OnramperSDK +0 -0
  13. package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/OnramperSDK.framework.dSYM/Contents/Resources/DWARF/OnramperSDK +0 -0
  14. package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/OnramperSDK.framework.dSYM/Contents/Resources/Relocations/aarch64/OnramperSDK.yml +3560 -2684
  15. package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/OnramperSDK.framework.dSYM/Contents/Resources/Relocations/x86_64/OnramperSDK.yml +3523 -2651
  16. package/ios/HybridOnramperCheckoutButton.swift +1 -1
  17. package/ios/HybridOnramperNitro.swift +140 -78
  18. package/ios/OnramperSDKBridge.swift +19 -2
  19. package/ios/PreparedIntentRegistry.swift +124 -6
  20. package/ios/SDKLogRelay.swift +41 -0
  21. package/lib/commonjs/OnramperClient.js +66 -5
  22. package/lib/commonjs/OnramperClient.js.map +1 -1
  23. package/lib/commonjs/errors.js +19 -5
  24. package/lib/commonjs/errors.js.map +1 -1
  25. package/lib/module/OnramperClient.js +66 -5
  26. package/lib/module/OnramperClient.js.map +1 -1
  27. package/lib/module/errors.js +19 -5
  28. package/lib/module/errors.js.map +1 -1
  29. package/lib/typescript/commonjs/src/OnramperClient.d.ts +5 -1
  30. package/lib/typescript/commonjs/src/OnramperClient.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/src/errors.d.ts +6 -1
  32. package/lib/typescript/commonjs/src/errors.d.ts.map +1 -1
  33. package/lib/typescript/commonjs/src/events.d.ts +10 -0
  34. package/lib/typescript/commonjs/src/events.d.ts.map +1 -1
  35. package/lib/typescript/commonjs/src/index.d.ts +1 -1
  36. package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/src/specs/OnramperNitro.nitro.d.ts +3 -0
  38. package/lib/typescript/commonjs/src/specs/OnramperNitro.nitro.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/src/types.d.ts +32 -3
  40. package/lib/typescript/commonjs/src/types.d.ts.map +1 -1
  41. package/lib/typescript/module/src/OnramperClient.d.ts +5 -1
  42. package/lib/typescript/module/src/OnramperClient.d.ts.map +1 -1
  43. package/lib/typescript/module/src/errors.d.ts +6 -1
  44. package/lib/typescript/module/src/errors.d.ts.map +1 -1
  45. package/lib/typescript/module/src/events.d.ts +10 -0
  46. package/lib/typescript/module/src/events.d.ts.map +1 -1
  47. package/lib/typescript/module/src/index.d.ts +1 -1
  48. package/lib/typescript/module/src/index.d.ts.map +1 -1
  49. package/lib/typescript/module/src/specs/OnramperNitro.nitro.d.ts +3 -0
  50. package/lib/typescript/module/src/specs/OnramperNitro.nitro.d.ts.map +1 -1
  51. package/lib/typescript/module/src/types.d.ts +32 -3
  52. package/lib/typescript/module/src/types.d.ts.map +1 -1
  53. package/nitrogen/generated/ios/OnramperReactNative-Swift-Cxx-Bridge.cpp +3 -3
  54. package/nitrogen/generated/ios/OnramperReactNative-Swift-Cxx-Bridge.hpp +7 -7
  55. package/nitrogen/generated/ios/c++/HybridOnramperNitroSpecSwift.hpp +6 -0
  56. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +5 -5
  57. package/nitrogen/generated/ios/swift/HybridOnramperNitroSpec.swift +1 -0
  58. package/nitrogen/generated/ios/swift/HybridOnramperNitroSpec_cxx.swift +27 -11
  59. package/nitrogen/generated/ios/swift/OnramperNitroConfig.swift +16 -6
  60. package/nitrogen/generated/shared/c++/HybridOnramperNitroSpec.cpp +1 -0
  61. package/nitrogen/generated/shared/c++/HybridOnramperNitroSpec.hpp +1 -0
  62. package/nitrogen/generated/shared/c++/OnramperNitroConfig.hpp +10 -2
  63. package/package.json +3 -2
  64. package/src/OnramperClient.ts +67 -4
  65. package/src/errors.ts +29 -6
  66. package/src/events.ts +5 -0
  67. package/src/index.ts +3 -0
  68. package/src/specs/OnramperNitro.nitro.ts +3 -0
  69. package/src/types.ts +36 -3
@@ -39,7 +39,7 @@ final class HybridOnramperCheckoutButton: HybridOnramperCheckoutButtonSpec {
39
39
  let entry = await PreparedIntentRegistry.shared.consume(handle)
40
40
  // A newer handle arrived while this one was being consumed; it owns the view.
41
41
  guard self.mountedHandle == handle else { return }
42
- guard let entry else {
42
+ guard let entry, entry.isValid else {
43
43
  // Handle was invalidated or already consumed. Remove whatever an earlier
44
44
  // handle hosted, so a stale Buy button can't stay on screen.
45
45
  self.container.clear()
@@ -9,51 +9,73 @@ import SwiftUI
9
9
  /// callbacks, and feeds the SDK's `sessionExpirationHandler` from a stored
10
10
  /// async JS callback. The public typed JS API lives in `OnramperClient.ts`.
11
11
  final class HybridOnramperNitro: HybridOnramperNitroSpec {
12
- private var client: OnramperClient?
13
- private var stateObservation: AnyCancellable?
14
- private var transactionIdObservation: AnyCancellable?
15
- private var eventTask: Task<Void, Never>?
16
-
17
- private var onState: ((String) -> Void)?
18
- private var onEvent: ((String) -> Void)?
19
- private var onTransactionId: ((String?) -> Void)?
20
- // Nitro wraps a callback's return value in a Promise; our callback already
21
- // returns Promise<NitroSessionCredentials>, hence the doubly-nested Promise.
22
- private var sessionHandler: (() -> Promise<Promise<NitroSessionCredentials>>)?
23
-
24
- // Handle of the most recent prepared intent owned by *this* client, used to
25
- // enforce single-flight per client without touching other clients' intents.
26
- private var lastPreparedHandle: String?
12
+ private let logRelay = SDKLogRelay()
13
+ @MainActor private var client: OnramperClient?
14
+ @MainActor private var stateObservation: AnyCancellable?
15
+ @MainActor private var transactionIdObservation: AnyCancellable?
16
+ @MainActor private var eventTask: Task<Void, Never>?
17
+
18
+ private let lifetime = PreparedIntentLifetime()
19
+ private let callbackLock = NSLock()
20
+ private struct Callbacks {
21
+ var onState: ((String) -> Void)?
22
+ var onEvent: ((String) -> Void)?
23
+ var onTransactionId: ((String?) -> Void)?
24
+ // Nitro wraps the async JS callback's own returned Promise.
25
+ var sessionHandler: (() -> Promise<Promise<NitroSessionCredentials>>)?
26
+ }
27
+ private var callbacks = Callbacks()
28
+
29
+ private func withCallbacks<Value>(_ body: (inout Callbacks) -> Value) -> Value {
30
+ callbackLock.lock()
31
+ defer { callbackLock.unlock() }
32
+ return body(&callbacks)
33
+ }
27
34
 
28
35
  // MARK: - Listener registration (synchronous)
29
36
 
37
+ func setLogListener(onLog: @escaping (String) -> Void) {
38
+ logRelay.setListener(onLog)
39
+ }
40
+
30
41
  func setStateListener(onState: @escaping (String) -> Void) {
31
- self.onState = onState
42
+ withCallbacks { $0.onState = onState }
32
43
  }
33
44
 
34
45
  func setEventListener(onEvent: @escaping (String) -> Void) {
35
- self.onEvent = onEvent
46
+ withCallbacks { $0.onEvent = onEvent }
36
47
  }
37
48
 
38
49
  func setTransactionIdListener(onTransactionId: @escaping (String?) -> Void) {
39
- self.onTransactionId = onTransactionId
50
+ withCallbacks { $0.onTransactionId = onTransactionId }
40
51
  }
41
52
 
42
53
  func setSessionExpirationHandler(handler: @escaping () -> Promise<Promise<NitroSessionCredentials>>) {
43
- self.sessionHandler = handler
54
+ withCallbacks { $0.sessionHandler = handler }
55
+ }
56
+
57
+ /// Also used by reset/signOut: invalidate synchronously before scheduling
58
+ /// asynchronous SDK work, so old results cannot become usable buttons.
59
+ private func beginPreparation() throws -> PreparedIntentLifetime.Ticket {
60
+ do { return try lifetime.begin() } catch { throw bridgeSDKError(error) }
44
61
  }
45
62
 
46
63
  // MARK: - Lifecycle
47
64
 
48
65
  func configure(config: OnramperNitroConfig) throws -> Promise<Void> {
49
66
  return Promise.async { [weak self] in
50
- guard let self else { return }
51
- try await self.performConfigure(config)
67
+ do {
68
+ guard let self else { throw BridgeLifecycleError.clientDestroyed }
69
+ try await self.performConfigure(config)
70
+ } catch {
71
+ throw bridgeSDKError(error)
72
+ }
52
73
  }
53
74
  }
54
75
 
55
76
  @MainActor
56
77
  private func performConfigure(_ config: OnramperNitroConfig) async throws {
78
+ try lifetime.checkActive()
57
79
  // Idempotent: a second configure() with the SDK already built is a no-op.
58
80
  if client != nil { return }
59
81
 
@@ -75,24 +97,34 @@ final class HybridOnramperNitro: HybridOnramperNitroSpec {
75
97
  }
76
98
  }()
77
99
 
100
+ let relay = logRelay
78
101
  let swiftConfig = OnramperConfiguration(
79
102
  apiKey: config.apiKey,
80
103
  clientId: config.clientId,
81
104
  environment: environment,
82
105
  theme: theme,
83
106
  logLevel: logLevel,
107
+ logHandler: { record in
108
+ OnramperLog.systemHandler(record)
109
+ relay.emit(record)
110
+ },
111
+ isEventLoggingEnabled: config.isEventLoggingEnabled,
112
+ isEventConsoleLoggingEnabled: config.isEventConsoleLoggingEnabled,
84
113
  sessionExpirationHandler: { [weak self] in
85
- guard let self, let handler = self.sessionHandler else {
114
+ guard let self, let handler = self.withCallbacks({ $0.sessionHandler }) else {
86
115
  throw OnramperError.userTokenRefreshFailed("no session expiration handler registered")
87
116
  }
88
117
  // Await twice: outer Promise = the callback invocation, inner Promise =
89
118
  // the JS async function's own returned Promise. Then map to the SDK type.
119
+ try self.lifetime.checkActive()
90
120
  let creds = try await handler().await().await()
121
+ try self.lifetime.checkActive()
91
122
  return SessionCredentials(sessionId: creds.sessionId, sessionToken: creds.sessionToken)
92
123
  }
93
124
  )
94
125
 
95
126
  let newClient = OnramperClient(configuration: swiftConfig)
127
+ try lifetime.checkActive()
96
128
  client = newClient
97
129
  attachObservers(to: newClient)
98
130
  }
@@ -100,10 +132,12 @@ final class HybridOnramperNitro: HybridOnramperNitroSpec {
100
132
  @MainActor
101
133
  private func attachObservers(to client: OnramperClient) {
102
134
  stateObservation = client.$state.sink { [weak self] state in
103
- self?.onState?(jsonString(state.toJSDict()))
135
+ guard let self, (try? self.lifetime.checkActive()) != nil else { return }
136
+ self.withCallbacks { $0.onState }?(jsonString(state.toJSDict()))
104
137
  }
105
138
  transactionIdObservation = client.$currentTransactionId.sink { [weak self] transactionId in
106
- self?.onTransactionId?(transactionId)
139
+ guard let self, (try? self.lifetime.checkActive()) != nil else { return }
140
+ self.withCallbacks { $0.onTransactionId }?(transactionId)
107
141
  }
108
142
  // `events` is a computed AsyncStream that captures a single continuation, so
109
143
  // access it exactly once here. CheckoutEvent is Sendable → safe to iterate
@@ -111,38 +145,59 @@ final class HybridOnramperNitro: HybridOnramperNitroSpec {
111
145
  let stream = client.events
112
146
  eventTask = Task { [weak self] in
113
147
  for await event in stream {
114
- self?.onEvent?(jsonString(event.toJSDict()))
148
+ guard let self, !Task.isCancelled, (try? self.lifetime.checkActive()) != nil else { break }
149
+ self.withCallbacks { $0.onEvent }?(jsonString(event.toJSDict()))
115
150
  }
116
151
  }
117
152
  }
118
153
 
119
154
  @MainActor
120
155
  private func requireClient() throws -> OnramperClient {
156
+ try lifetime.checkActive()
121
157
  guard let client else { throw OnramperError.notInitialized }
122
158
  return client
123
159
  }
124
160
 
125
161
  func initialize(sessionId: String, sessionToken: String) throws -> Promise<Void> {
126
162
  return Promise.async { [weak self] in
127
- guard let self else { return }
128
- let client = try await self.requireClient()
129
- try await client.initialize(sessionId: sessionId, sessionToken: sessionToken)
163
+ do {
164
+ guard let self else { throw BridgeLifecycleError.clientDestroyed }
165
+ let client = try await self.requireClient()
166
+ try await client.initialize(sessionId: sessionId, sessionToken: sessionToken)
167
+ try self.lifetime.checkActive()
168
+ } catch {
169
+ throw bridgeSDKError(error)
170
+ }
130
171
  }
131
172
  }
132
173
 
133
174
  func reset() throws -> Promise<Void> {
175
+ _ = try beginPreparation()
134
176
  return Promise.async { [weak self] in
135
- guard let self else { return }
136
- let client = try await self.requireClient()
137
- await client.reset()
177
+ do {
178
+ guard let self else { throw BridgeLifecycleError.clientDestroyed }
179
+ let client = try await self.requireClient()
180
+ await PreparedIntentRegistry.shared.dropInvalidated(for: self.lifetime)
181
+ await client.reset()
182
+ try self.lifetime.checkActive()
183
+ } catch {
184
+ throw bridgeSDKError(error)
185
+ }
138
186
  }
139
187
  }
140
188
 
141
189
  func signOut() throws -> Promise<Void> {
190
+ _ = try beginPreparation()
142
191
  return Promise.async { [weak self] in
143
- guard let self else { return }
144
- let client = try await self.requireClient()
145
- await client.signOut()
192
+ do {
193
+ guard let self else { throw BridgeLifecycleError.clientDestroyed }
194
+ let client = try await self.requireClient()
195
+ await PreparedIntentRegistry.shared.dropInvalidated(for: self.lifetime)
196
+ await client.signOut()
197
+ try self.lifetime.checkActive()
198
+ } catch {
199
+ throw bridgeSDKError(error)
200
+ }
146
201
  }
147
202
  }
148
203
 
@@ -153,13 +208,19 @@ final class HybridOnramperNitro: HybridOnramperNitroSpec {
153
208
  prefillJson: String,
154
209
  styleJson: String
155
210
  ) throws -> Promise<PreparedIntentResult> {
211
+ let ticket = try beginPreparation()
156
212
  return Promise.async { [weak self] in
157
- guard let self else { throw OnramperError.notInitialized }
158
- return try await self.performGetCheckoutRequirements(
159
- requestJson: requestJson,
160
- prefillJson: prefillJson,
161
- styleJson: styleJson
162
- )
213
+ do {
214
+ guard let self else { throw BridgeLifecycleError.clientDestroyed }
215
+ return try await self.performGetCheckoutRequirements(
216
+ requestJson: requestJson,
217
+ prefillJson: prefillJson,
218
+ styleJson: styleJson,
219
+ ticket: ticket
220
+ )
221
+ } catch {
222
+ throw bridgeSDKError(error)
223
+ }
163
224
  }
164
225
  }
165
226
 
@@ -167,37 +228,35 @@ final class HybridOnramperNitro: HybridOnramperNitroSpec {
167
228
  private func performGetCheckoutRequirements(
168
229
  requestJson: String,
169
230
  prefillJson: String,
170
- styleJson: String
231
+ styleJson: String,
232
+ ticket: PreparedIntentLifetime.Ticket
171
233
  ) async throws -> PreparedIntentResult {
172
- let client = try requireClient()
173
- let request = try decodeCheckoutRequest(requestJson)
174
- // Best-effort: an unusable prefill payload decodes to nil and the checkout
175
- // proceeds without it. Never logged — the values are user PII, and the SDK
176
- // makes the same guarantee at every log level.
177
- let prefill = decodeUserPrefill(prefillJson)
178
- let style = decodeCheckoutButtonStyle(styleJson)
179
-
180
- // Single-flight, scoped to this client: a new prepared intent supersedes
181
- // this instance's prior unconsumed one, but leaves other clients' intents
182
- // in the shared registry untouched.
183
- if let prev = lastPreparedHandle {
184
- await PreparedIntentRegistry.shared.drop(prev)
234
+ let prepared = try await PreparedIntentRegistry.shared.prepare(ticket: ticket) { [self] in
235
+ try ticket.check()
236
+ let client = try requireClient()
237
+ let request = try decodeCheckoutRequest(requestJson)
238
+ // Best-effort; prefill values are never logged.
239
+ let prefill = decodeUserPrefill(prefillJson)
240
+ let style = decodeCheckoutButtonStyle(styleJson)
241
+ let result = try await client.getCheckoutRequirements(request, prefill: prefill, buttonStyle: style)
242
+ let entry = PreparedIntentRegistry.PreparedIntent(button: AnyView(result.button), createdAt: Date())
243
+ let quoteDict = (codableToJSValue(result.quote) as? [String: Any]) ?? [:]
244
+ return (entry, jsonString(quoteDict))
185
245
  }
186
-
187
- let result = try await client.getCheckoutRequirements(request, prefill: prefill, buttonStyle: style)
188
- let entry = PreparedIntentRegistry.PreparedIntent(button: AnyView(result.button), createdAt: Date())
189
- let handle = await PreparedIntentRegistry.shared.store(entry)
190
- lastPreparedHandle = handle
191
- let quoteDict = (codableToJSValue(result.quote) as? [String: Any]) ?? [:]
192
- return PreparedIntentResult(intentHandle: handle, quoteJson: jsonString(quoteDict))
246
+ try await PreparedIntentRegistry.shared.validateDelivery(of: prepared.handle, ticket: ticket)
247
+ return PreparedIntentResult(intentHandle: prepared.handle, quoteJson: prepared.value)
193
248
  }
194
249
 
195
250
  func cancelPreparedIntent(intentHandle: String) throws -> Promise<Void> {
196
- if intentHandle == lastPreparedHandle {
197
- lastPreparedHandle = nil
198
- }
199
- return Promise.async {
200
- await PreparedIntentRegistry.shared.drop(intentHandle)
251
+ return Promise.async { [weak self] in
252
+ do {
253
+ guard let self else { throw BridgeLifecycleError.clientDestroyed }
254
+ try self.lifetime.checkActive()
255
+ await PreparedIntentRegistry.shared.drop(intentHandle, owner: self.lifetime)
256
+ try self.lifetime.checkActive()
257
+ } catch {
258
+ throw bridgeSDKError(error)
259
+ }
201
260
  }
202
261
  }
203
262
 
@@ -206,17 +265,20 @@ final class HybridOnramperNitro: HybridOnramperNitroSpec {
206
265
  /// Releases the stored JS callbacks (Nitro holds strong refs to them) and the
207
266
  /// SDK client. Called from `OnramperClient.destroy()` in JS.
208
267
  func dispose() {
209
- stateObservation?.cancel()
210
- stateObservation = nil
211
- transactionIdObservation?.cancel()
212
- transactionIdObservation = nil
213
- eventTask?.cancel()
214
- eventTask = nil
215
- onState = nil
216
- onEvent = nil
217
- onTransactionId = nil
218
- sessionHandler = nil
219
- lastPreparedHandle = nil
220
- client = nil
268
+ guard lifetime.destroy() else { return }
269
+ logRelay.setListener(nil)
270
+ withCallbacks { $0 = Callbacks() }
271
+ // SDK ownership and Combine subscriptions belong to the main actor.
272
+ // Tickets are already invalid even if this cleanup has not run yet.
273
+ Task { @MainActor [self] in
274
+ stateObservation?.cancel()
275
+ stateObservation = nil
276
+ transactionIdObservation?.cancel()
277
+ transactionIdObservation = nil
278
+ eventTask?.cancel()
279
+ eventTask = nil
280
+ client = nil
281
+ await PreparedIntentRegistry.shared.dropInvalidated(for: lifetime)
282
+ }
221
283
  }
222
284
  }
@@ -19,6 +19,9 @@ extension OnramperError {
19
19
  case .networkError: return "networkError"
20
20
  case .decodingError: return "decodingError"
21
21
  case .timeout: return "timeout"
22
+ case .paymentFailed: return "paymentFailed"
23
+ case .providerFailed: return "providerFailed"
24
+ case .applePayNotConfigured: return "applePayNotConfigured"
22
25
  case .requirementNotSatisfied: return "requirementNotSatisfied"
23
26
  case .amountOutOfRange: return "amountOutOfRange"
24
27
  case .oidcFlowCancelled: return "oidcFlowCancelled"
@@ -73,9 +76,14 @@ extension OnramperError {
73
76
  }
74
77
  }
75
78
 
76
- /// A JS-friendly error dict: `{ code, message, info? }`.
79
+ /// A JS-friendly error dict, including customer-facing copy and support code.
77
80
  func toJSDict() -> [String: Any] {
78
- var dict: [String: Any] = ["code": jsCode, "message": errorDescription ?? "Unknown error"]
81
+ var dict: [String: Any] = [
82
+ "code": jsCode,
83
+ "message": errorDescription ?? "Unknown error",
84
+ "userMessage": userMessage,
85
+ "supportCode": supportCode,
86
+ ]
79
87
  if let info = jsInfo { dict["info"] = info }
80
88
  return dict
81
89
  }
@@ -149,6 +157,7 @@ extension OnramperState {
149
157
  "renderType": ctx.renderType.jsValue,
150
158
  "paymentType": ctx.paymentType.jsValue,
151
159
  ]
160
+ case .paymentPending: return ["kind": "paymentPending"]
152
161
  case .completed: return ["kind": "completed"]
153
162
  case .failed(let error):
154
163
  return ["kind": "failed", "error": error.toJSDict()]
@@ -188,6 +197,14 @@ extension CheckoutEvent {
188
197
  return ["type": "paymentAuthorized"]
189
198
  case .paymentProcessing:
190
199
  return ["type": "paymentProcessing"]
200
+ case .paymentPending:
201
+ return ["type": "paymentPending"]
202
+ case .challengeStarted:
203
+ return ["type": "challengeStarted"]
204
+ case .challengeCompleted:
205
+ return ["type": "challengeCompleted"]
206
+ case .customerOnboardingRequired:
207
+ return ["type": "customerOnboardingRequired"]
191
208
  case .paymentCancelled:
192
209
  return ["type": "paymentCancelled"]
193
210
  case .providerError(let reason):
@@ -2,6 +2,65 @@ import Foundation
2
2
  import Security
3
3
  import SwiftUI
4
4
 
5
+ /// Issued synchronously at the Nitro boundary, so request order does not depend
6
+ /// on when Promise.async happens to reach the main actor. Disposal can also
7
+ /// invalidate tickets immediately from the JS thread.
8
+ final class PreparedIntentLifetime: @unchecked Sendable {
9
+ private let lock = NSLock()
10
+ private var generation = UUID()
11
+ private var destroyed = false
12
+
13
+ struct Ticket {
14
+ fileprivate let owner: PreparedIntentLifetime
15
+ fileprivate let generation: UUID
16
+
17
+ func check() throws { try owner.check(generation: generation) }
18
+ }
19
+
20
+ func begin() throws -> Ticket {
21
+ lock.lock()
22
+ defer { lock.unlock() }
23
+ guard !destroyed else { throw BridgeLifecycleError.clientDestroyed }
24
+ generation = UUID()
25
+ return Ticket(owner: self, generation: generation)
26
+ }
27
+
28
+ func checkActive() throws { try check(generation: nil) }
29
+
30
+ private func check(generation expected: UUID?) throws {
31
+ lock.lock()
32
+ defer { lock.unlock() }
33
+ guard !destroyed else { throw BridgeLifecycleError.clientDestroyed }
34
+ if let expected, expected != generation { throw BridgeLifecycleError.intentInvalidated }
35
+ }
36
+
37
+ func destroy() -> Bool {
38
+ lock.lock()
39
+ defer { lock.unlock() }
40
+ guard !destroyed else { return false }
41
+ destroyed = true
42
+ return true
43
+ }
44
+ }
45
+
46
+ enum BridgeLifecycleError: String, Error {
47
+ case clientDestroyed
48
+ case intentInvalidated
49
+
50
+ var payload: [String: Any] {
51
+ switch self {
52
+ case .clientDestroyed:
53
+ return ["code": rawValue, "supportCode": rawValue,
54
+ "message": "This OnramperClient has been destroyed. Create a new client before making SDK calls.",
55
+ "userMessage": "This checkout session has ended. Please reopen checkout."]
56
+ case .intentInvalidated:
57
+ return ["code": rawValue, "supportCode": rawValue,
58
+ "message": "This checkout preparation was superseded or cleared. Use the most recent request.",
59
+ "userMessage": "Your checkout details have changed. Please use the latest quote."]
60
+ }
61
+ }
62
+ }
63
+
5
64
  actor PreparedIntentRegistry {
6
65
  /// Process-wide registry. `getCheckoutRequirements` (on HybridOnramperNitro)
7
66
  /// stores the SwiftUI button under a ULID handle; the separate
@@ -12,10 +71,23 @@ actor PreparedIntentRegistry {
12
71
  struct PreparedIntent {
13
72
  let button: AnyView
14
73
  let createdAt: Date
15
- var consumed: Bool = false
74
+ fileprivate var ticket: PreparedIntentLifetime.Ticket?
75
+
76
+ init(button: AnyView, createdAt: Date) {
77
+ self.button = button
78
+ self.createdAt = createdAt
79
+ }
80
+
81
+ // The main-actor view rechecks after the hop from the registry actor.
82
+ var isValid: Bool {
83
+ guard let ticket else { return true }
84
+ do { try ticket.check(); return true } catch { return false }
85
+ }
16
86
  }
17
87
 
18
88
  private var entries: [String: PreparedIntent] = [:]
89
+ // Internal diagnostics used by the standalone native regression suite.
90
+ var count: Int { entries.count }
19
91
  private let ttl: TimeInterval
20
92
 
21
93
  init(ttl: TimeInterval = 300) {
@@ -29,17 +101,63 @@ actor PreparedIntentRegistry {
29
101
  return handle
30
102
  }
31
103
 
104
+ /// The bridge uses this path so stale successes AND stale errors are
105
+ /// rejected consistently, including SDK cancellation errors. The operation
106
+ /// may still finish on the server; this only controls local button ownership.
107
+ func prepare<Value>(
108
+ ticket: PreparedIntentLifetime.Ticket,
109
+ operation: @MainActor () async throws -> (PreparedIntent, Value)
110
+ ) async throws -> (handle: String, value: Value) {
111
+ dropInvalidated(for: ticket.owner)
112
+ try ticket.check()
113
+ do {
114
+ let (entry, value) = try await operation()
115
+ try ticket.check()
116
+ sweepExpired()
117
+ let handle = Self.makeULID()
118
+ var ownedEntry = entry
119
+ ownedEntry.ticket = ticket
120
+ entries[handle] = ownedEntry
121
+ return (handle, value)
122
+ } catch {
123
+ try ticket.check()
124
+ throw error
125
+ }
126
+ }
127
+
32
128
  func consume(_ handle: String) -> PreparedIntent? {
33
- guard var entry = entries[handle], !entry.consumed else { return nil }
34
- entry.consumed = true
35
- entries[handle] = entry
36
- return entry
129
+ // Transfer ownership to the mounted view instead of retaining consumed
130
+ // SwiftUI views until some future quote request triggers a sweep.
131
+ guard let entry = entries.removeValue(forKey: handle),
132
+ Date().timeIntervalSince(entry.createdAt) < ttl else { return nil }
133
+ return entry.isValid ? entry : nil
134
+ }
135
+
136
+ /// Validate after the registry-to-main-actor hop, immediately before the
137
+ /// bridge returns the handle. A rejected result never reaches JavaScript,
138
+ /// so it must release its stored view without waiting for another sweep.
139
+ @MainActor
140
+ func validateDelivery(of handle: String, ticket: PreparedIntentLifetime.Ticket) async throws {
141
+ do {
142
+ try ticket.check()
143
+ } catch {
144
+ await drop(handle, owner: ticket.owner)
145
+ throw error
146
+ }
37
147
  }
38
148
 
39
- func drop(_ handle: String) {
149
+ func drop(_ handle: String, owner: PreparedIntentLifetime? = nil) {
150
+ if let owner, entries[handle]?.ticket?.owner !== owner { return }
40
151
  entries.removeValue(forKey: handle)
41
152
  }
42
153
 
154
+ func dropInvalidated(for owner: PreparedIntentLifetime) {
155
+ entries = entries.filter { _, entry in
156
+ guard let ticket = entry.ticket, ticket.owner === owner else { return true }
157
+ do { try ticket.check(); return true } catch { return false }
158
+ }
159
+ }
160
+
43
161
  func invalidateAll() {
44
162
  entries.removeAll()
45
163
  }
@@ -0,0 +1,41 @@
1
+ import Foundation
2
+ import NitroModules
3
+ import OnramperSDK
4
+
5
+ /// The SDK can emit records from any thread. Keep callback registration and
6
+ /// teardown synchronized; invoke outside the lock (Nitro schedules delivery).
7
+ final class SDKLogRelay: @unchecked Sendable {
8
+ private let lock = NSLock()
9
+ private var listener: ((String) -> Void)?
10
+
11
+ func setListener(_ listener: ((String) -> Void)?) {
12
+ lock.lock()
13
+ self.listener = listener
14
+ lock.unlock()
15
+ }
16
+
17
+ func emit(_ record: OnramperLogRecord) {
18
+ lock.lock()
19
+ let callback = listener
20
+ lock.unlock()
21
+ guard let callback else { return }
22
+ callback(jsonString([
23
+ "timestamp": record.timestamp.timeIntervalSince1970 * 1000,
24
+ "level": String(describing: record.level),
25
+ "category": record.category,
26
+ "event": record.event,
27
+ "context": record.context,
28
+ "message": record.message,
29
+ ]))
30
+ }
31
+ }
32
+
33
+ /// Nitro otherwise converts a Swift error to a message-only JavaScript Error.
34
+ /// Both failed events and rejected operations must carry the SDK's safe copy.
35
+ func bridgeSDKError(_ error: Error) -> Error {
36
+ if let lifecycleError = error as? BridgeLifecycleError {
37
+ return RuntimeError.error(withMessage: "ONRAMPER_ERROR:" + jsonString(lifecycleError.payload))
38
+ }
39
+ guard let sdkError = error as? OnramperError else { return error }
40
+ return RuntimeError.error(withMessage: "ONRAMPER_ERROR:" + jsonString(sdkError.toJSDict()))
41
+ }