react-native-revnix 1.4.0 → 1.5.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.
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ import type { RevnixConfig } from "./types.js";
4
4
  export { RevnixClient } from "./client.js";
5
5
  export { PurchaseBlockedError, RevnixAuthError, RevnixError, RevnixNetworkError, RevnixRateLimitError, RevnixTimeoutError, } from "./errors.js";
6
6
  export { purchaseInputFromIap, type IapPurchase } from "./iap.js";
7
+ export { setStoreAdapter, type StoreAdapter, type StoreTransaction, } from "./store.js";
7
8
  export { memoryStorage, generateAnonymousId } from "./storage.js";
8
9
  export { SDK_VERSION } from "./version.js";
9
10
  export { DEVICE_HEADER, encodeDeviceHeader } from "./device.js";
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.Revnix = exports.PREVIEW_PLACEMENT_KEY = exports.IMPLICIT_PLACEMENTS = exports.HOST_CALL_PLACEMENT = exports.DEFAULT_SESSION_TIMEOUT_MS = exports.browserLifecycle = exports.encodeDeviceHeader = exports.DEVICE_HEADER = exports.SDK_VERSION = exports.generateAnonymousId = exports.memoryStorage = exports.purchaseInputFromIap = exports.RevnixTimeoutError = exports.RevnixRateLimitError = exports.RevnixNetworkError = exports.RevnixError = exports.RevnixAuthError = exports.PurchaseBlockedError = exports.RevnixClient = void 0;
3
+ exports.Revnix = exports.PREVIEW_PLACEMENT_KEY = exports.IMPLICIT_PLACEMENTS = exports.HOST_CALL_PLACEMENT = exports.DEFAULT_SESSION_TIMEOUT_MS = exports.browserLifecycle = exports.encodeDeviceHeader = exports.DEVICE_HEADER = exports.SDK_VERSION = exports.generateAnonymousId = exports.memoryStorage = exports.setStoreAdapter = exports.purchaseInputFromIap = exports.RevnixTimeoutError = exports.RevnixRateLimitError = exports.RevnixNetworkError = exports.RevnixError = exports.RevnixAuthError = exports.PurchaseBlockedError = exports.RevnixClient = void 0;
4
4
  const client_js_1 = require("./client.js");
5
5
  const locale_js_1 = require("./blocks/locale.js");
6
6
  var client_js_2 = require("./client.js");
@@ -14,6 +14,8 @@ Object.defineProperty(exports, "RevnixRateLimitError", { enumerable: true, get:
14
14
  Object.defineProperty(exports, "RevnixTimeoutError", { enumerable: true, get: function () { return errors_js_1.RevnixTimeoutError; } });
15
15
  var iap_js_1 = require("./iap.js");
16
16
  Object.defineProperty(exports, "purchaseInputFromIap", { enumerable: true, get: function () { return iap_js_1.purchaseInputFromIap; } });
17
+ var store_js_1 = require("./store.js");
18
+ Object.defineProperty(exports, "setStoreAdapter", { enumerable: true, get: function () { return store_js_1.setStoreAdapter; } });
17
19
  var storage_js_1 = require("./storage.js");
18
20
  Object.defineProperty(exports, "memoryStorage", { enumerable: true, get: function () { return storage_js_1.memoryStorage; } });
19
21
  Object.defineProperty(exports, "generateAnonymousId", { enumerable: true, get: function () { return storage_js_1.generateAnonymousId; } });
@@ -0,0 +1,2 @@
1
+ import { type StoreAdapter } from "../store.js";
2
+ export declare const RevnixStore: StoreAdapter;
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RevnixStore = void 0;
4
+ const react_native_1 = require("react-native");
5
+ const store_js_1 = require("../store.js");
6
+ const native = react_native_1.NativeModules.RevnixStore;
7
+ const missing = () => Promise.reject(new Error("Revnix: RevnixStore native module not found. Run pod install / rebuild the app (Expo: use a development build)."));
8
+ exports.RevnixStore = native
9
+ ? {
10
+ purchase: (productId, { customerId }) => native.purchase(productId, customerId),
11
+ restore: () => native.restore(),
12
+ finish: (transactionId) => native.finish(transactionId),
13
+ observe(onTransaction) {
14
+ const sub = new react_native_1.NativeEventEmitter(react_native_1.NativeModules.RevnixStore).addListener("revnixStoreTransaction", (tx) => onTransaction(tx));
15
+ return () => sub.remove();
16
+ },
17
+ }
18
+ : {
19
+ purchase: missing,
20
+ restore: missing,
21
+ finish: missing,
22
+ observe: () => () => { },
23
+ };
24
+ if (native)
25
+ (0, store_js_1.setStoreAdapter)(exports.RevnixStore);
@@ -0,0 +1,30 @@
1
+ import type { Store } from "./types.js";
2
+ export interface StoreTransaction {
3
+ store: Store;
4
+ productId: string;
5
+ /** Apple: transaction id · Google: purchaseToken. */
6
+ transactionId: string;
7
+ /** Apple: originalTransactionId · Google: purchaseToken. */
8
+ token: string;
9
+ /** Unix ms. */
10
+ occurredAt?: number;
11
+ /** Unix ms. */
12
+ expiresAt?: number;
13
+ /** Apple only: the StoreKit 2 JWS. */
14
+ signedTransactionInfo?: string;
15
+ }
16
+ export interface StoreAdapter {
17
+ /** Resolves null when the customer cancelled or the purchase is pending. */
18
+ purchase(productId: string, opts: {
19
+ customerId: string;
20
+ }): Promise<StoreTransaction | null>;
21
+ restore(): Promise<StoreTransaction[]>;
22
+ finish(transactionId: string): Promise<void>;
23
+ /** Transactions arriving outside purchase(): renewals, other devices, Ask
24
+ * to Buy, refunds. Returns an unsubscribe. */
25
+ observe(onTransaction: (tx: StoreTransaction) => void): () => void;
26
+ }
27
+ /** Installs the store that `RevnixClient.purchase()` / `restore()` drive.
28
+ * `import "react-native-revnix/store"` calls this with the native bridge. */
29
+ export declare function setStoreAdapter(next: StoreAdapter | null): void;
30
+ export declare function getStoreAdapter(): StoreAdapter | null;
package/dist/store.js ADDED
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.setStoreAdapter = setStoreAdapter;
4
+ exports.getStoreAdapter = getStoreAdapter;
5
+ let adapter = null;
6
+ /** Installs the store that `RevnixClient.purchase()` / `restore()` drive.
7
+ * `import "react-native-revnix/store"` calls this with the native bridge. */
8
+ function setStoreAdapter(next) {
9
+ adapter = next;
10
+ }
11
+ function getStoreAdapter() {
12
+ return adapter;
13
+ }
package/dist/types.d.ts CHANGED
@@ -379,7 +379,7 @@ export interface PaywallConfig {
379
379
  * When present RevnixPaywall renders THIS and the fields above act as the
380
380
  * fallback for clients that predate block rendering — so an older app
381
381
  * keeps showing a sane classic screen instead of nothing. Typed as the
382
- * block document in "revnix-react/ui"; the core entrypoint keeps it opaque
382
+ * block document in "react-native-revnix/ui"; the core entrypoint keeps it opaque
383
383
  * so non-UI consumers pull in no renderer types. */
384
384
  blocks?: unknown;
385
385
  }
@@ -1,6 +1,7 @@
1
1
  import * as React from "react";
2
2
  import { type StyleProp, type ViewStyle } from "react-native";
3
- import type { PaywallConfig, PaywallInteraction, PaywallPurchaseOutcome } from "../types.js";
3
+ import type { PaywallConfig, PaywallInteraction, PaywallPurchaseOutcome, RegisterPurchaseResult } from "../types.js";
4
+ import type { RevnixClient } from "../client.js";
4
5
  import type { FontFamilyMap } from "../blocks/style.js";
5
6
  import { type RevnixBlurComponent, type RevnixGradientComponent } from "./RevnixBlockPaywall.js";
6
7
  /** One purchasable row. `priceLabel` must come from the store (localized). */
@@ -51,15 +52,21 @@ export interface RevnixPaywallProps {
51
52
  * `paywall.purchase_failed`, and a promise that REJECTS is reported as a
52
53
  * failure carrying the error's message. Returning nothing keeps today's
53
54
  * behavior exactly — the start is reported, the outcome is not, and you
54
- * can still report it yourself with `client.logPaywallEvent`. */
55
- onPurchase: (packageId: string) => void | PaywallPurchaseOutcome | Promise<PaywallPurchaseOutcome | void>;
55
+ * can still report it yourself with `client.logPaywallEvent`.
56
+ *
57
+ * Omit it to let the paywall buy through `client.purchase()`, which needs
58
+ * `import "react-native-revnix/store"`. */
59
+ onPurchase?: (packageId: string) => void | PaywallPurchaseOutcome | Promise<PaywallPurchaseOutcome | void>;
56
60
  /** Controlled selection; omit to let the paywall manage it (initial
57
61
  * selection is the config's highlight package, else the first package). */
58
62
  selectedPackageId?: string;
59
63
  onSelectPackage?: (packageId: string) => void;
60
64
  /** Renders a spinner in the CTA and disables purchasing. */
61
65
  loading?: boolean;
66
+ /** Omit it to restore through `client.restore()`. */
62
67
  onRestore?: () => void;
68
+ /** Fires when the paywall's own purchase (no `onPurchase`) succeeds. */
69
+ onPurchaseCompleted?: (result: RegisterPurchaseResult) => void;
63
70
  onTerms?: () => void;
64
71
  onPrivacy?: () => void;
65
72
  /** Called when the customer dismisses the paywall (REV-252) — either by
@@ -104,6 +111,9 @@ export interface RevnixPaywallProps {
104
111
  message?: string;
105
112
  eventId?: string;
106
113
  }) => void;
114
+ /** Used when `onPurchase` / `onRestore` are omitted. */
115
+ purchase?: RevnixClient["purchase"];
116
+ restore?: RevnixClient["restore"];
107
117
  };
108
118
  /** Placement/paywall attribution attached to the view report. */
109
119
  placementKey?: string;
@@ -13,7 +13,7 @@
13
13
  // An unrecognized template (config published by a newer dashboard) falls
14
14
  // back to the classic structure instead of rendering nothing.
15
15
  //
16
- // Import from "revnix-react/ui" — the core entrypoint stays free of
16
+ // Import from "react-native-revnix/ui" — the core entrypoint stays free of
17
17
  // react / react-native so non-RN consumers are unaffected.
18
18
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
19
19
  if (k2 === undefined) k2 = k;
@@ -54,6 +54,7 @@ exports.RevnixPaywall = RevnixPaywall;
54
54
  const React = __importStar(require("react"));
55
55
  const react_native_1 = require("react-native");
56
56
  const implicit_js_1 = require("../implicit.js");
57
+ const store_js_1 = require("../store.js");
57
58
  const color_js_1 = require("../blocks/color.js");
58
59
  const RevnixBlockPaywall_js_1 = require("./RevnixBlockPaywall.js");
59
60
  /** `config.blocks` is opaque on the core config (the non-UI entrypoint must
@@ -178,7 +179,7 @@ function RevnixPaywall(props) {
178
179
  });
179
180
  onSelectPackage?.(packageId);
180
181
  }, [onSelectPackage, report, productOf]);
181
- const { onPurchase } = props;
182
+ const { onPurchase, onPurchaseCompleted } = props;
182
183
  // Rises per attempt so a retry after a failure is its own occurrence rather
183
184
  // than being absorbed as a duplicate of the first try.
184
185
  const attemptRef = React.useRef(0);
@@ -205,9 +206,26 @@ function RevnixPaywall(props) {
205
206
  eventId: occurrence,
206
207
  });
207
208
  };
209
+ const purchase = onPurchase ??
210
+ (async () => {
211
+ if (!productId) {
212
+ return { status: "failed", message: "package has no productId" };
213
+ }
214
+ if (!client?.purchase) {
215
+ return { status: "failed", message: "pass client or onPurchase" };
216
+ }
217
+ const result = await client.purchase(productId, {
218
+ viewId: viewIdRef.current,
219
+ placementKey,
220
+ paywallId,
221
+ });
222
+ if (result)
223
+ onPurchaseCompleted?.(result);
224
+ return { status: result ? "completed" : "cancelled" };
225
+ });
208
226
  let returned;
209
227
  try {
210
- returned = onPurchase(packageId);
228
+ returned = purchase(packageId);
211
229
  }
212
230
  catch (err) {
213
231
  // A host that throws synchronously has failed the purchase as surely
@@ -220,14 +238,23 @@ function RevnixPaywall(props) {
220
238
  return;
221
239
  }
222
240
  settle(returned);
223
- }, [onPurchase, report, productOf, placementKey]);
241
+ }, [onPurchase, onPurchaseCompleted, report, productOf, placementKey, paywallId, client]);
224
242
  const { onRestore } = props;
225
- const handleRestore = React.useMemo(() => onRestore
226
- ? () => {
227
- report("restore");
228
- onRestore();
243
+ const handleRestore = React.useMemo(() => {
244
+ if (onRestore) {
245
+ return () => {
246
+ report("restore");
247
+ onRestore();
248
+ };
229
249
  }
230
- : undefined, [onRestore, report]);
250
+ const restore = client?.restore?.bind(client);
251
+ if (!restore || !(0, store_js_1.getStoreAdapter)())
252
+ return undefined;
253
+ return () => {
254
+ report("restore");
255
+ void restore().catch((err) => client?.reportRenderDiagnostic?.(String(err?.message ?? err)));
256
+ };
257
+ }, [onRestore, report, client]);
231
258
  // The host's dismissal runs whatever happens to the beacon — an analytics
232
259
  // failure must never trap the customer on the screen (REV-252).
233
260
  const { onClose } = props;
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "1.4.0";
1
+ export declare const SDK_VERSION = "1.5.0";
package/dist/version.js CHANGED
@@ -4,4 +4,4 @@ exports.SDK_VERSION = void 0;
4
4
  // L3: the single source of the SDK version inside src/. Must match
5
5
  // package.json "version" — version.test.ts fails the build when they drift
6
6
  // (they had: client.ts said 0.7.0 while the package shipped 0.9.0).
7
- exports.SDK_VERSION = "1.4.0";
7
+ exports.SDK_VERSION = "1.5.0";
@@ -0,0 +1,18 @@
1
+ #import <React/RCTBridgeModule.h>
2
+ #import <React/RCTEventEmitter.h>
3
+
4
+ @interface RCT_EXTERN_MODULE(RevnixStore, RCTEventEmitter)
5
+
6
+ RCT_EXTERN_METHOD(purchase:(NSString *)productId
7
+ customerId:(NSString *)customerId
8
+ resolver:(RCTPromiseResolveBlock)resolve
9
+ rejecter:(RCTPromiseRejectBlock)reject)
10
+
11
+ RCT_EXTERN_METHOD(restore:(RCTPromiseResolveBlock)resolve
12
+ rejecter:(RCTPromiseRejectBlock)reject)
13
+
14
+ RCT_EXTERN_METHOD(finish:(NSString *)transactionId
15
+ resolver:(RCTPromiseResolveBlock)resolve
16
+ rejecter:(RCTPromiseRejectBlock)reject)
17
+
18
+ @end
@@ -0,0 +1,106 @@
1
+ import Foundation
2
+ import React
3
+ import StoreKit
4
+
5
+ @available(iOS 15.0, *)
6
+ @objc(RevnixStore)
7
+ class RevnixStore: RCTEventEmitter {
8
+ private var updates: Task<Void, Never>?
9
+
10
+ @objc override static func requiresMainQueueSetup() -> Bool {
11
+ false
12
+ }
13
+
14
+ override func supportedEvents() -> [String]! {
15
+ ["revnixStoreTransaction"]
16
+ }
17
+
18
+ override func startObserving() {
19
+ updates?.cancel()
20
+ updates = Task { [weak self] in
21
+ for await update in Transaction.updates where update.unsafePayloadValue.revocationDate == nil {
22
+ self?.sendEvent(withName: "revnixStoreTransaction", body: RevnixStore.payload(update))
23
+ }
24
+ }
25
+ }
26
+
27
+ override func stopObserving() {
28
+ updates?.cancel()
29
+ updates = nil
30
+ }
31
+
32
+ @objc(purchase:customerId:resolver:rejecter:)
33
+ func purchase(
34
+ _ productId: String,
35
+ customerId: String,
36
+ resolver resolve: @escaping RCTPromiseResolveBlock,
37
+ rejecter reject: @escaping RCTPromiseRejectBlock
38
+ ) {
39
+ Task {
40
+ do {
41
+ guard let product = try await Product.products(for: [productId]).first else {
42
+ reject("product_not_found", "No App Store product with id \(productId)", nil)
43
+ return
44
+ }
45
+ switch try await product.purchase() {
46
+ case .success(let verification):
47
+ resolve(RevnixStore.payload(verification))
48
+ default:
49
+ resolve(nil)
50
+ }
51
+ } catch {
52
+ reject("store_error", error.localizedDescription, error)
53
+ }
54
+ }
55
+ }
56
+
57
+ @objc(restore:rejecter:)
58
+ func restore(
59
+ _ resolve: @escaping RCTPromiseResolveBlock,
60
+ rejecter reject: @escaping RCTPromiseRejectBlock
61
+ ) {
62
+ Task {
63
+ try? await AppStore.sync()
64
+ var transactions: [[String: Any]] = []
65
+ for await entitlement in Transaction.currentEntitlements
66
+ where entitlement.unsafePayloadValue.revocationDate == nil {
67
+ transactions.append(RevnixStore.payload(entitlement))
68
+ }
69
+ resolve(transactions)
70
+ }
71
+ }
72
+
73
+ @objc(finish:resolver:rejecter:)
74
+ func finish(
75
+ _ transactionId: String,
76
+ resolver resolve: @escaping RCTPromiseResolveBlock,
77
+ rejecter reject: @escaping RCTPromiseRejectBlock
78
+ ) {
79
+ Task {
80
+ for await unfinished in Transaction.unfinished {
81
+ let transaction = unfinished.unsafePayloadValue
82
+ if String(transaction.id) == transactionId {
83
+ await transaction.finish()
84
+ break
85
+ }
86
+ }
87
+ resolve(nil)
88
+ }
89
+ }
90
+
91
+ private static func payload(_ verification: VerificationResult<Transaction>) -> [String: Any] {
92
+ let transaction = verification.unsafePayloadValue
93
+ var payload: [String: Any] = [
94
+ "store": "apple",
95
+ "productId": transaction.productID,
96
+ "transactionId": String(transaction.id),
97
+ "token": String(transaction.originalID),
98
+ "occurredAt": Int(transaction.purchaseDate.timeIntervalSince1970 * 1000),
99
+ "signedTransactionInfo": verification.jwsRepresentation,
100
+ ]
101
+ if let expiresAt = transaction.expirationDate {
102
+ payload["expiresAt"] = Int(expiresAt.timeIntervalSince1970 * 1000)
103
+ }
104
+ return payload
105
+ }
106
+ }
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "react-native-revnix",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Revnix SDK for React, React Native & Next.js: purchase registration, entitlements, identity, and placements over the Revnix /v1 API.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/Oth-tech/revnix-sdk.git"
9
9
  },
10
+ "homepage": "https://www.revnix.io/docs/react-native",
11
+ "bugs": {
12
+ "email": "support@revnix.io"
13
+ },
10
14
  "keywords": [
11
15
  "revnix",
12
16
  "react-native",
@@ -24,7 +28,11 @@
24
28
  "main": "dist/index.js",
25
29
  "module": "dist/esm/index.js",
26
30
  "types": "dist/index.d.ts",
27
- "sideEffects": false,
31
+ "sideEffects": [
32
+ "./store.js",
33
+ "./dist/store/index.js",
34
+ "./dist/esm/store/index.js"
35
+ ],
28
36
  "exports": {
29
37
  ".": {
30
38
  "types": "./dist/index.d.ts",
@@ -36,6 +44,11 @@
36
44
  "import": "./dist/esm/ui/index.js",
37
45
  "require": "./ui.js"
38
46
  },
47
+ "./store": {
48
+ "types": "./store.d.ts",
49
+ "import": "./dist/esm/store/index.js",
50
+ "require": "./store.js"
51
+ },
39
52
  "./package.json": "./package.json"
40
53
  },
41
54
  "files": [
@@ -43,6 +56,11 @@
43
56
  "src",
44
57
  "ui.js",
45
58
  "ui.d.ts",
59
+ "store.js",
60
+ "store.d.ts",
61
+ "ios",
62
+ "android",
63
+ "react-native-revnix.podspec",
46
64
  "!src/**/*.test.ts"
47
65
  ],
48
66
  "scripts": {
@@ -0,0 +1,22 @@
1
+ require "json"
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = "react-native-revnix"
7
+ s.version = package["version"]
8
+ s.summary = package["description"]
9
+ s.homepage = package["homepage"]
10
+ s.license = package["license"]
11
+ s.author = "Revnix"
12
+ s.platforms = { :ios => "15.1" }
13
+ s.source = { :git => "https://github.com/Oth-tech/revnix-sdk.git", :tag => "v#{s.version}" }
14
+ s.source_files = "ios/**/*.{h,m,swift}"
15
+ s.swift_version = "5.0"
16
+
17
+ if respond_to?(:install_modules_dependencies, true)
18
+ install_modules_dependencies(s)
19
+ else
20
+ s.dependency "React-Core"
21
+ end
22
+ end
package/src/client.ts CHANGED
@@ -20,6 +20,11 @@ import {
20
20
  encodeDeviceHeader,
21
21
  } from "./device.js";
22
22
  import { SDK_VERSION } from "./version.js";
23
+ import {
24
+ getStoreAdapter,
25
+ type StoreAdapter,
26
+ type StoreTransaction,
27
+ } from "./store.js";
23
28
  import {
24
29
  browserLifecycle,
25
30
  DEFAULT_SESSION_TIMEOUT_MS,
@@ -185,6 +190,7 @@ export class RevnixClient {
185
190
  private implicitConfigPromise: Promise<ReadonlySet<string>> | null = null;
186
191
  private implicitConfigRetryAt = 0;
187
192
  private lifecycleUnsubscribe: (() => void) | null = null;
193
+ private stopStoreObserver: (() => void) | null = null;
188
194
  /** The cold-start batch while it runs, resolving to whether it presented a
189
195
  * paywall. A deep link delivered on the first frame — SwiftUI's onOpenURL,
190
196
  * RN's getInitialURL — must wait for it, or the customer gets the launch
@@ -253,6 +259,14 @@ export class RevnixClient {
253
259
  void this.retryPendingPurchases().catch((err) =>
254
260
  this.diag("retryPendingPurchases", err)
255
261
  );
262
+ const store = getStoreAdapter();
263
+ if (store) {
264
+ this.stopStoreObserver = store.observe((tx) =>
265
+ void this.registerStoreTransaction(store, tx).catch((err) =>
266
+ this.diag("storeObserver", err)
267
+ )
268
+ );
269
+ }
256
270
  // REV-157: warm the placement cache at launch, so the first paywall of the
257
271
  // session can render offline. Fire-and-forget; a failed key just stays on
258
272
  // whatever an earlier launch cached.
@@ -425,6 +439,77 @@ export class RevnixClient {
425
439
  }
426
440
  }
427
441
 
442
+ /** Buys `productId` through the native store (StoreKit 2 / Play Billing),
443
+ * registers it with Revnix, then finishes the store transaction. Resolves
444
+ * null when the customer cancelled or the purchase is pending approval.
445
+ * Needs `import "react-native-revnix/store"` before configure(). A refused
446
+ * claim (PurchaseBlockedError) is never finished, so Google refunds it. */
447
+ async purchase(
448
+ productId: string,
449
+ opts?: Pick<RegisterPurchaseInput, "viewId" | "paywallId" | "placementKey">
450
+ ): Promise<RegisterPurchaseResult | null> {
451
+ const store = this.requireStore();
452
+ const customerId = await this.customerIdPromise;
453
+ const tx = await store.purchase(productId, { customerId });
454
+ if (!tx) return null;
455
+ return this.registerStoreTransaction(store, tx, opts);
456
+ }
457
+
458
+ /** Re-registers every purchase the store still holds for this device and
459
+ * resolves how many registered. Per-purchase failures go to onDiagnostic. */
460
+ async restore(): Promise<number> {
461
+ const store = this.requireStore();
462
+ let restored = 0;
463
+ for (const tx of await store.restore()) {
464
+ try {
465
+ await this.registerStoreTransaction(store, tx, undefined, tx.store === "google");
466
+ restored += 1;
467
+ } catch (err) {
468
+ this.diag("storeRestore", err);
469
+ }
470
+ }
471
+ return restored;
472
+ }
473
+
474
+ private requireStore(): StoreAdapter {
475
+ const store = getStoreAdapter();
476
+ if (!store) {
477
+ throw new Error(
478
+ 'Revnix: native purchases need `import "react-native-revnix/store"` before Revnix.configure(...) and a rebuilt app (pod install / Expo development build)'
479
+ );
480
+ }
481
+ return store;
482
+ }
483
+
484
+ private async registerStoreTransaction(
485
+ store: StoreAdapter,
486
+ tx: StoreTransaction,
487
+ opts?: Pick<RegisterPurchaseInput, "viewId" | "paywallId" | "placementKey">,
488
+ finish = true
489
+ ): Promise<RegisterPurchaseResult> {
490
+ const finishTx = () =>
491
+ finish
492
+ ? store.finish(tx.transactionId).catch((err) => this.diag("storeFinish", err))
493
+ : undefined;
494
+ try {
495
+ const result = await this.registerPurchase({
496
+ source: tx.store,
497
+ token: tx.token,
498
+ productId: tx.productId,
499
+ transactionId: tx.transactionId,
500
+ occurredAt: tx.occurredAt,
501
+ expiresAt: tx.expiresAt,
502
+ signedTransactionInfo: tx.signedTransactionInfo,
503
+ ...opts,
504
+ });
505
+ await finishTx();
506
+ return result;
507
+ } catch (err) {
508
+ if (!(err instanceof PurchaseBlockedError)) await finishTx();
509
+ throw err;
510
+ }
511
+ }
512
+
428
513
  private postPurchase(
429
514
  customerId: string,
430
515
  input: RegisterPurchaseInput
@@ -1532,6 +1617,8 @@ export class RevnixClient {
1532
1617
  // every return alongside its successor.
1533
1618
  this.lifecycleUnsubscribe?.();
1534
1619
  this.lifecycleUnsubscribe = null;
1620
+ this.stopStoreObserver?.();
1621
+ this.stopStoreObserver = null;
1535
1622
  }
1536
1623
 
1537
1624
  /** A block paywall reporting a paint string it could not read. Routed to the
@@ -1922,7 +2009,7 @@ export class RevnixClient {
1922
2009
  Authorization: `Bearer ${this.apiKey}`,
1923
2010
  // L3: identify the SDK on EVERY request (reads included) so the
1924
2011
  // backend can triage by version without depending on body fields.
1925
- "X-Revnix-SDK": `revnix-react/${SDK_VERSION}`,
2012
+ "X-Revnix-SDK": `react-native-revnix/${SDK_VERSION}`,
1926
2013
  ...(reportedBgFailures > 0
1927
2014
  ? { "X-Revnix-Bg-Failures": String(reportedBgFailures) }
1928
2015
  : {}),
package/src/index.ts CHANGED
@@ -12,6 +12,11 @@ export {
12
12
  RevnixTimeoutError,
13
13
  } from "./errors.js";
14
14
  export { purchaseInputFromIap, type IapPurchase } from "./iap.js";
15
+ export {
16
+ setStoreAdapter,
17
+ type StoreAdapter,
18
+ type StoreTransaction,
19
+ } from "./store.js";
15
20
  export { memoryStorage, generateAnonymousId } from "./storage.js";
16
21
  export { SDK_VERSION } from "./version.js";
17
22
  export { DEVICE_HEADER, encodeDeviceHeader } from "./device.js";
@@ -0,0 +1,44 @@
1
+ import { NativeEventEmitter, NativeModules } from "react-native";
2
+
3
+ import {
4
+ setStoreAdapter,
5
+ type StoreAdapter,
6
+ type StoreTransaction,
7
+ } from "../store.js";
8
+
9
+ interface NativeRevnixStore {
10
+ purchase(productId: string, customerId: string): Promise<StoreTransaction | null>;
11
+ restore(): Promise<StoreTransaction[]>;
12
+ finish(transactionId: string): Promise<void>;
13
+ }
14
+
15
+ const native: NativeRevnixStore | undefined = NativeModules.RevnixStore;
16
+
17
+ const missing = (): Promise<never> =>
18
+ Promise.reject(
19
+ new Error(
20
+ "Revnix: RevnixStore native module not found. Run pod install / rebuild the app (Expo: use a development build)."
21
+ )
22
+ );
23
+
24
+ export const RevnixStore: StoreAdapter = native
25
+ ? {
26
+ purchase: (productId, { customerId }) => native.purchase(productId, customerId),
27
+ restore: () => native.restore(),
28
+ finish: (transactionId) => native.finish(transactionId),
29
+ observe(onTransaction) {
30
+ const sub = new NativeEventEmitter(NativeModules.RevnixStore).addListener(
31
+ "revnixStoreTransaction",
32
+ (tx) => onTransaction(tx as StoreTransaction)
33
+ );
34
+ return () => sub.remove();
35
+ },
36
+ }
37
+ : {
38
+ purchase: missing,
39
+ restore: missing,
40
+ finish: missing,
41
+ observe: () => () => {},
42
+ };
43
+
44
+ if (native) setStoreAdapter(RevnixStore);