react-native-revnix 1.8.0 → 1.9.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/README.md CHANGED
@@ -104,6 +104,7 @@ Your API key and deployment URL are in the dashboard under Settings. See [API ke
104
104
  **Revnix handles the hard parts of subscriptions in a small, developer-friendly SDK.**
105
105
 
106
106
  - `purchase(productId)` runs the whole flow: store sheet, server-side receipt validation, entitlement, transaction finish. `restore()` brings purchases back on a new device.
107
+ - Consumables on Google Play: list their ids in `consumableProductIds` so they are consumed after Revnix records the purchase and can be bought again.
107
108
  - Renewals, purchases on other devices and Ask to Buy approvals arrive on their own and are registered automatically.
108
109
  - Entitlements and placements are cached on device, so paid users stay unlocked offline.
109
110
  - Failed registrations are retried automatically when the app comes back online.
@@ -9,6 +9,7 @@ import com.android.billingclient.api.BillingClient.ProductType
9
9
  import com.android.billingclient.api.BillingClientStateListener
10
10
  import com.android.billingclient.api.BillingFlowParams
11
11
  import com.android.billingclient.api.BillingResult
12
+ import com.android.billingclient.api.ConsumeParams
12
13
  import com.android.billingclient.api.PendingPurchasesParams
13
14
  import com.android.billingclient.api.ProductDetails
14
15
  import com.android.billingclient.api.Purchase
@@ -96,10 +97,9 @@ class RevnixStoreModule(context: ReactApplicationContext) :
96
97
  }
97
98
  }
98
99
 
99
- // ponytail: acknowledges every purchase; consumable INAPP products need consumeAsync to be bought again
100
100
  @ReactMethod
101
- fun finish(purchaseToken: String, promise: Promise) {
102
- if (purchaseToken in acknowledged) {
101
+ fun finish(purchaseToken: String, consume: Boolean, promise: Promise) {
102
+ if (!consume && purchaseToken in acknowledged) {
103
103
  promise.resolve(null)
104
104
  return
105
105
  }
@@ -108,13 +108,32 @@ class RevnixStoreModule(context: ReactApplicationContext) :
108
108
  promise.reject("store_error", "${setup.responseCode} ${setup.debugMessage}")
109
109
  return@withBilling
110
110
  }
111
- val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchaseToken).build()
112
- billing.acknowledgePurchase(params) { result ->
113
- if (result.responseCode == BillingResponseCode.OK) {
111
+ val acknowledge = { onResult: (BillingResult) -> Unit ->
112
+ val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchaseToken).build()
113
+ billing.acknowledgePurchase(params) { result ->
114
+ if (result.responseCode == BillingResponseCode.OK) acknowledged.add(purchaseToken)
115
+ onResult(result)
116
+ }
117
+ }
118
+ if (!consume) {
119
+ acknowledge { result ->
120
+ if (result.responseCode == BillingResponseCode.OK) promise.resolve(null)
121
+ else promise.reject("store_error", "acknowledgePurchase failed: ${result.responseCode} ${result.debugMessage}")
122
+ }
123
+ return@withBilling
124
+ }
125
+ val params = ConsumeParams.newBuilder().setPurchaseToken(purchaseToken).build()
126
+ billing.consumeAsync(params) { consumed, _ ->
127
+ if (consumed.responseCode == BillingResponseCode.OK) {
114
128
  acknowledged.add(purchaseToken)
115
129
  promise.resolve(null)
116
130
  } else {
117
- promise.reject("store_error", "acknowledgePurchase failed: ${result.responseCode} ${result.debugMessage}")
131
+ acknowledge { acked ->
132
+ promise.reject(
133
+ "store_error",
134
+ "consumeAsync failed: ${consumed.responseCode} ${consumed.debugMessage}; acknowledgePurchase fallback: ${acked.responseCode} ${acked.debugMessage}",
135
+ )
136
+ }
118
137
  }
119
138
  }
120
139
  }
@@ -193,7 +212,7 @@ class RevnixStoreModule(context: ReactApplicationContext) :
193
212
  if (result.responseCode == BillingResponseCode.OK) {
194
213
  queryPurchases(ProductType.SUBS) { subs ->
195
214
  queryPurchases(ProductType.INAPP) { inapp ->
196
- (subs + inapp).filterNot { it.isAcknowledged }.forEach { emit(toMap(it)) }
215
+ (subs.filterNot { it.isAcknowledged } + inapp).forEach { emit(toMap(it)) }
197
216
  }
198
217
  }
199
218
  }
@@ -210,7 +229,7 @@ class RevnixStoreModule(context: ReactApplicationContext) :
210
229
  BillingResponseCode.ITEM_ALREADY_OWNED -> queryPurchases(ProductType.SUBS) { subs ->
211
230
  queryPurchases(ProductType.INAPP) { inapp ->
212
231
  val owned = (subs + inapp).firstOrNull { waiting.productId in it.products }
213
- if (owned != null) waiting.promise.resolve(toMap(owned))
232
+ if (owned != null) waiting.promise.resolve(toMap(owned).apply { putBoolean("alreadyOwned", true) })
214
233
  else waiting.promise.reject("store_error", "${result.responseCode} ${result.debugMessage}")
215
234
  }
216
235
  }
@@ -274,6 +293,7 @@ class RevnixStoreModule(context: ReactApplicationContext) :
274
293
  putString("transactionId", purchase.purchaseToken)
275
294
  putString("token", purchase.purchaseToken)
276
295
  putDouble("occurredAt", purchase.purchaseTime.toDouble())
296
+ putBoolean("acknowledged", purchase.isAcknowledged)
277
297
  }
278
298
  }
279
299
 
package/dist/client.d.ts CHANGED
@@ -27,6 +27,7 @@ export declare class RevnixClient {
27
27
  private readonly onDeferredDeepLink?;
28
28
  private readonly onAttribution?;
29
29
  private readonly deviceIntegrity?;
30
+ private readonly consumableProductIds;
30
31
  private integrityRequest;
31
32
  private deferredDeepLinkClaimed;
32
33
  private landingUrl;
@@ -106,6 +107,7 @@ export declare class RevnixClient {
106
107
  /** Re-registers every purchase the store still holds for this device and
107
108
  * resolves how many registered. Per-purchase failures go to onDiagnostic. */
108
109
  restore(): Promise<number>;
110
+ private isConsumable;
109
111
  private requireStore;
110
112
  private registerStoreTransaction;
111
113
  private postPurchase;
package/dist/client.js CHANGED
@@ -97,6 +97,32 @@ const pendingKey = (p) => `${p.source}:${p.token}:${p.transactionId}`;
97
97
  // revoked key, blocked transfer) would be retried every launch forever;
98
98
  // unknown non-Revnix errors count as transient to err on the keeping side.
99
99
  const worthQueueing = (err) => !(err instanceof errors_js_1.RevnixError) || err.isRetryable;
100
+ const ATTRIBUTION_QUERY_PARAMS = new Set([
101
+ "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "utm_id", "campaign_id",
102
+ "pid", "source", "af_channel", "medium", "c", "campaign", "term", "af_ad",
103
+ "content", "gclid", "gbraid", "wbraid", "ttclid", "sccid", "twclid",
104
+ "revnix_link", "web_id",
105
+ ]);
106
+ function queryParamName(pair) {
107
+ const raw = pair.split("=")[0] ?? "";
108
+ try {
109
+ return decodeURIComponent(raw.replace(/\+/g, " ")).toLowerCase();
110
+ }
111
+ catch {
112
+ return raw.toLowerCase();
113
+ }
114
+ }
115
+ function attributionOnlyUrl(rawUrl) {
116
+ const url = rawUrl.trim();
117
+ const parts = /^([a-z][a-z0-9+.-]*:)(\/\/[^/?#]*)?([^?#]*)(?:\?([^#]*))?/i.exec(url);
118
+ if (!parts)
119
+ return url.split(/[?#]/)[0] ?? "";
120
+ const [, scheme, authority = "", path, query = ""] = parts;
121
+ const kept = query
122
+ .split("&")
123
+ .filter((pair) => ATTRIBUTION_QUERY_PARAMS.has(queryParamName(pair)));
124
+ return `${scheme}${authority.replace(/^\/\/.*@/, "//")}${path}${kept.length > 0 ? `?${kept.join("&")}` : ""}`;
125
+ }
100
126
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
101
127
  // REV-199 (CTO-R2 M24): ±20% on every scheduled delay.
102
128
  const jittered = (ms) => ms * (0.8 + Math.random() * 0.4);
@@ -207,6 +233,7 @@ class RevnixClient {
207
233
  this.onDeferredDeepLink = config.onDeferredDeepLink;
208
234
  this.onAttribution = config.onAttribution;
209
235
  this.deviceIntegrity = config.deviceIntegrity;
236
+ this.consumableProductIds = config.consumableProductIds ?? [];
210
237
  this.readYourWritesDelaysMs =
211
238
  config.readYourWritesDelaysMs ?? [250, 500, 1000, 2000];
212
239
  this.customerIdPromise = this.loadCustomerId(config.customerId);
@@ -217,7 +244,11 @@ class RevnixClient {
217
244
  this.reportAdvertisingId(config.advertisingId);
218
245
  const store = (0, store_js_1.getStoreAdapter)();
219
246
  if (store) {
220
- this.stopStoreObserver = store.observe((tx) => void this.registerStoreTransaction(store, tx).catch((err) => this.diag("storeObserver", err)));
247
+ this.stopStoreObserver = store.observe((tx) => {
248
+ if (tx.acknowledged && !this.isConsumable(tx))
249
+ return;
250
+ void this.registerStoreTransaction(store, tx).catch((err) => this.diag("storeObserver", err));
251
+ });
221
252
  }
222
253
  // REV-157: warm the placement cache at launch, so the first paywall of the
223
254
  // session can render offline. Fire-and-forget; a failed key just stays on
@@ -378,6 +409,10 @@ class RevnixClient {
378
409
  const tx = await store.purchase(productId, { customerId });
379
410
  if (!tx)
380
411
  return null;
412
+ if (tx.alreadyOwned && this.isConsumable(tx)) {
413
+ await this.registerStoreTransaction(store, tx, opts);
414
+ throw new errors_js_1.PurchaseAlreadyOwnedError(`Revnix: an earlier purchase of ${productId} was still owned and has been registered; buy again`);
415
+ }
381
416
  return this.registerStoreTransaction(store, tx, opts);
382
417
  }
383
418
  /** Re-registers every purchase the store still holds for this device and
@@ -396,6 +431,9 @@ class RevnixClient {
396
431
  }
397
432
  return restored;
398
433
  }
434
+ isConsumable(tx) {
435
+ return tx.store === "google" && this.consumableProductIds.includes(tx.productId);
436
+ }
399
437
  requireStore() {
400
438
  const store = (0, store_js_1.getStoreAdapter)();
401
439
  if (!store) {
@@ -404,8 +442,8 @@ class RevnixClient {
404
442
  return store;
405
443
  }
406
444
  async registerStoreTransaction(store, tx, opts, finish = true) {
407
- const finishTx = () => finish
408
- ? store.finish(tx.transactionId).catch((err) => this.diag("storeFinish", err))
445
+ const finishTx = (consume = false) => finish
446
+ ? store.finish(tx.transactionId, consume).catch((err) => this.diag("storeFinish", err))
409
447
  : undefined;
410
448
  try {
411
449
  const result = await this.registerPurchase({
@@ -418,7 +456,7 @@ class RevnixClient {
418
456
  signedTransactionInfo: tx.signedTransactionInfo,
419
457
  ...opts,
420
458
  });
421
- await finishTx();
459
+ await finishTx(this.isConsumable(tx));
422
460
  return result;
423
461
  }
424
462
  catch (err) {
@@ -1099,7 +1137,7 @@ class RevnixClient {
1099
1137
  return;
1100
1138
  }
1101
1139
  void this.rememberDeepLink(url);
1102
- const extra = { url: url.slice(0, 1024) };
1140
+ const extra = { url: attributionOnlyUrl(url).slice(0, 1024) };
1103
1141
  const batch = this.launchBatch;
1104
1142
  if (batch === null) {
1105
1143
  this.fireImplicit("deeplink_open", extra);
package/dist/errors.d.ts CHANGED
@@ -11,6 +11,12 @@ export declare class RevnixError extends Error {
11
11
  export declare class PurchaseBlockedError extends RevnixError {
12
12
  constructor(message: string);
13
13
  }
14
+ /** purchase() found an earlier Google Play consumable of this product still
15
+ * owned. It was registered and consumed instead of charging again; ask the
16
+ * customer to buy again. */
17
+ export declare class PurchaseAlreadyOwnedError extends Error {
18
+ constructor(message: string);
19
+ }
14
20
  /** C7: the request didn't complete within the configured timeout (default
15
21
  * 10s). `fetch` has no default timeout on React Native / browsers, so without
16
22
  * this a slow or dead backend would hang the app until the OS TCP timeout. */
package/dist/errors.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.RevnixRateLimitError = exports.RevnixAuthError = exports.RevnixNetworkError = exports.RevnixTimeoutError = exports.PurchaseBlockedError = exports.RevnixError = void 0;
3
+ exports.RevnixRateLimitError = exports.RevnixAuthError = exports.RevnixNetworkError = exports.RevnixTimeoutError = exports.PurchaseAlreadyOwnedError = exports.PurchaseBlockedError = exports.RevnixError = void 0;
4
4
  class RevnixError extends Error {
5
5
  constructor(status, message) {
6
6
  super(message);
@@ -24,6 +24,16 @@ class PurchaseBlockedError extends RevnixError {
24
24
  }
25
25
  }
26
26
  exports.PurchaseBlockedError = PurchaseBlockedError;
27
+ /** purchase() found an earlier Google Play consumable of this product still
28
+ * owned. It was registered and consumed instead of charging again; ask the
29
+ * customer to buy again. */
30
+ class PurchaseAlreadyOwnedError extends Error {
31
+ constructor(message) {
32
+ super(message);
33
+ this.name = "PurchaseAlreadyOwnedError";
34
+ }
35
+ }
36
+ exports.PurchaseAlreadyOwnedError = PurchaseAlreadyOwnedError;
27
37
  /** C7: the request didn't complete within the configured timeout (default
28
38
  * 10s). `fetch` has no default timeout on React Native / browsers, so without
29
39
  * this a slow or dead backend would hang the app until the OS TCP timeout. */
@@ -1,4 +1,4 @@
1
- import { PurchaseBlockedError, RevnixAuthError, RevnixError, RevnixNetworkError, RevnixRateLimitError, RevnixTimeoutError, } from "./errors.js";
1
+ import { PurchaseAlreadyOwnedError, PurchaseBlockedError, RevnixAuthError, RevnixError, RevnixNetworkError, RevnixRateLimitError, RevnixTimeoutError, } from "./errors.js";
2
2
  import { generateAnonymousId, generateEventId, generateViewId, resolveStorage, } from "./storage.js";
3
3
  import { DEVICE_HEADER, INSTALLED_AT_KEY, compactPayload, detectDeviceFacts, encodeDeviceHeader, } from "./device.js";
4
4
  import { SDK_VERSION } from "./version.js";
@@ -93,6 +93,32 @@ const pendingKey = (p) => `${p.source}:${p.token}:${p.transactionId}`;
93
93
  // revoked key, blocked transfer) would be retried every launch forever;
94
94
  // unknown non-Revnix errors count as transient to err on the keeping side.
95
95
  const worthQueueing = (err) => !(err instanceof RevnixError) || err.isRetryable;
96
+ const ATTRIBUTION_QUERY_PARAMS = new Set([
97
+ "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "utm_id", "campaign_id",
98
+ "pid", "source", "af_channel", "medium", "c", "campaign", "term", "af_ad",
99
+ "content", "gclid", "gbraid", "wbraid", "ttclid", "sccid", "twclid",
100
+ "revnix_link", "web_id",
101
+ ]);
102
+ function queryParamName(pair) {
103
+ const raw = pair.split("=")[0] ?? "";
104
+ try {
105
+ return decodeURIComponent(raw.replace(/\+/g, " ")).toLowerCase();
106
+ }
107
+ catch {
108
+ return raw.toLowerCase();
109
+ }
110
+ }
111
+ function attributionOnlyUrl(rawUrl) {
112
+ const url = rawUrl.trim();
113
+ const parts = /^([a-z][a-z0-9+.-]*:)(\/\/[^/?#]*)?([^?#]*)(?:\?([^#]*))?/i.exec(url);
114
+ if (!parts)
115
+ return url.split(/[?#]/)[0] ?? "";
116
+ const [, scheme, authority = "", path, query = ""] = parts;
117
+ const kept = query
118
+ .split("&")
119
+ .filter((pair) => ATTRIBUTION_QUERY_PARAMS.has(queryParamName(pair)));
120
+ return `${scheme}${authority.replace(/^\/\/.*@/, "//")}${path}${kept.length > 0 ? `?${kept.join("&")}` : ""}`;
121
+ }
96
122
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
97
123
  // REV-199 (CTO-R2 M24): ±20% on every scheduled delay.
98
124
  const jittered = (ms) => ms * (0.8 + Math.random() * 0.4);
@@ -203,6 +229,7 @@ export class RevnixClient {
203
229
  this.onDeferredDeepLink = config.onDeferredDeepLink;
204
230
  this.onAttribution = config.onAttribution;
205
231
  this.deviceIntegrity = config.deviceIntegrity;
232
+ this.consumableProductIds = config.consumableProductIds ?? [];
206
233
  this.readYourWritesDelaysMs =
207
234
  config.readYourWritesDelaysMs ?? [250, 500, 1000, 2000];
208
235
  this.customerIdPromise = this.loadCustomerId(config.customerId);
@@ -213,7 +240,11 @@ export class RevnixClient {
213
240
  this.reportAdvertisingId(config.advertisingId);
214
241
  const store = getStoreAdapter();
215
242
  if (store) {
216
- this.stopStoreObserver = store.observe((tx) => void this.registerStoreTransaction(store, tx).catch((err) => this.diag("storeObserver", err)));
243
+ this.stopStoreObserver = store.observe((tx) => {
244
+ if (tx.acknowledged && !this.isConsumable(tx))
245
+ return;
246
+ void this.registerStoreTransaction(store, tx).catch((err) => this.diag("storeObserver", err));
247
+ });
217
248
  }
218
249
  // REV-157: warm the placement cache at launch, so the first paywall of the
219
250
  // session can render offline. Fire-and-forget; a failed key just stays on
@@ -374,6 +405,10 @@ export class RevnixClient {
374
405
  const tx = await store.purchase(productId, { customerId });
375
406
  if (!tx)
376
407
  return null;
408
+ if (tx.alreadyOwned && this.isConsumable(tx)) {
409
+ await this.registerStoreTransaction(store, tx, opts);
410
+ throw new PurchaseAlreadyOwnedError(`Revnix: an earlier purchase of ${productId} was still owned and has been registered; buy again`);
411
+ }
377
412
  return this.registerStoreTransaction(store, tx, opts);
378
413
  }
379
414
  /** Re-registers every purchase the store still holds for this device and
@@ -392,6 +427,9 @@ export class RevnixClient {
392
427
  }
393
428
  return restored;
394
429
  }
430
+ isConsumable(tx) {
431
+ return tx.store === "google" && this.consumableProductIds.includes(tx.productId);
432
+ }
395
433
  requireStore() {
396
434
  const store = getStoreAdapter();
397
435
  if (!store) {
@@ -400,8 +438,8 @@ export class RevnixClient {
400
438
  return store;
401
439
  }
402
440
  async registerStoreTransaction(store, tx, opts, finish = true) {
403
- const finishTx = () => finish
404
- ? store.finish(tx.transactionId).catch((err) => this.diag("storeFinish", err))
441
+ const finishTx = (consume = false) => finish
442
+ ? store.finish(tx.transactionId, consume).catch((err) => this.diag("storeFinish", err))
405
443
  : undefined;
406
444
  try {
407
445
  const result = await this.registerPurchase({
@@ -414,7 +452,7 @@ export class RevnixClient {
414
452
  signedTransactionInfo: tx.signedTransactionInfo,
415
453
  ...opts,
416
454
  });
417
- await finishTx();
455
+ await finishTx(this.isConsumable(tx));
418
456
  return result;
419
457
  }
420
458
  catch (err) {
@@ -1095,7 +1133,7 @@ export class RevnixClient {
1095
1133
  return;
1096
1134
  }
1097
1135
  void this.rememberDeepLink(url);
1098
- const extra = { url: url.slice(0, 1024) };
1136
+ const extra = { url: attributionOnlyUrl(url).slice(0, 1024) };
1099
1137
  const batch = this.launchBatch;
1100
1138
  if (batch === null) {
1101
1139
  this.fireImplicit("deeplink_open", extra);
@@ -19,6 +19,15 @@ export class PurchaseBlockedError extends RevnixError {
19
19
  this.name = "PurchaseBlockedError";
20
20
  }
21
21
  }
22
+ /** purchase() found an earlier Google Play consumable of this product still
23
+ * owned. It was registered and consumed instead of charging again; ask the
24
+ * customer to buy again. */
25
+ export class PurchaseAlreadyOwnedError extends Error {
26
+ constructor(message) {
27
+ super(message);
28
+ this.name = "PurchaseAlreadyOwnedError";
29
+ }
30
+ }
22
31
  /** C7: the request didn't complete within the configured timeout (default
23
32
  * 10s). `fetch` has no default timeout on React Native / browsers, so without
24
33
  * this a slow or dead backend would hang the app until the OS TCP timeout. */
package/dist/esm/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { RevnixClient } from "./client.js";
2
2
  import { setLocale } from "./blocks/locale.js";
3
3
  export { RevnixClient } from "./client.js";
4
- export { PurchaseBlockedError, RevnixAuthError, RevnixError, RevnixNetworkError, RevnixRateLimitError, RevnixTimeoutError, } from "./errors.js";
4
+ export { PurchaseAlreadyOwnedError, PurchaseBlockedError, RevnixAuthError, RevnixError, RevnixNetworkError, RevnixRateLimitError, RevnixTimeoutError, } from "./errors.js";
5
5
  export { purchaseInputFromIap } from "./iap.js";
6
6
  export { setStoreAdapter, } from "./store.js";
7
7
  export { memoryStorage, generateAnonymousId } from "./storage.js";
@@ -6,7 +6,7 @@ export const RevnixStore = native
6
6
  ? {
7
7
  purchase: (productId, { customerId }) => native.purchase(productId, customerId),
8
8
  restore: () => native.restore(),
9
- finish: (transactionId) => native.finish(transactionId),
9
+ finish: (transactionId, consume) => native.finish(transactionId, consume === true),
10
10
  observe(onTransaction) {
11
11
  const sub = new NativeEventEmitter(NativeModules.RevnixStore).addListener("revnixStoreTransaction", (tx) => onTransaction(tx));
12
12
  return () => sub.remove();
@@ -1,4 +1,4 @@
1
1
  // L3: the single source of the SDK version inside src/. Must match
2
2
  // package.json "version" — version.test.ts fails the build when they drift
3
3
  // (they had: client.ts said 0.7.0 while the package shipped 0.9.0).
4
- export const SDK_VERSION = "1.8.0";
4
+ export const SDK_VERSION = "1.9.0";
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ import { RevnixClient } from "./client.js";
2
2
  import { setLocale } from "./blocks/locale.js";
3
3
  import type { RevnixConfig } from "./types.js";
4
4
  export { RevnixClient } from "./client.js";
5
- export { PurchaseBlockedError, RevnixAuthError, RevnixError, RevnixNetworkError, RevnixRateLimitError, RevnixTimeoutError, } from "./errors.js";
5
+ export { PurchaseAlreadyOwnedError, PurchaseBlockedError, RevnixAuthError, RevnixError, RevnixNetworkError, RevnixRateLimitError, RevnixTimeoutError, } from "./errors.js";
6
6
  export { purchaseInputFromIap, type IapPurchase } from "./iap.js";
7
7
  export { setStoreAdapter, type StoreAdapter, type StoreTransaction, } from "./store.js";
8
8
  export { memoryStorage, generateAnonymousId } from "./storage.js";
package/dist/index.js CHANGED
@@ -1,11 +1,12 @@
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.setStoreAdapter = 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.PurchaseAlreadyOwnedError = 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");
7
7
  Object.defineProperty(exports, "RevnixClient", { enumerable: true, get: function () { return client_js_2.RevnixClient; } });
8
8
  var errors_js_1 = require("./errors.js");
9
+ Object.defineProperty(exports, "PurchaseAlreadyOwnedError", { enumerable: true, get: function () { return errors_js_1.PurchaseAlreadyOwnedError; } });
9
10
  Object.defineProperty(exports, "PurchaseBlockedError", { enumerable: true, get: function () { return errors_js_1.PurchaseBlockedError; } });
10
11
  Object.defineProperty(exports, "RevnixAuthError", { enumerable: true, get: function () { return errors_js_1.RevnixAuthError; } });
11
12
  Object.defineProperty(exports, "RevnixError", { enumerable: true, get: function () { return errors_js_1.RevnixError; } });
@@ -9,7 +9,7 @@ exports.RevnixStore = native
9
9
  ? {
10
10
  purchase: (productId, { customerId }) => native.purchase(productId, customerId),
11
11
  restore: () => native.restore(),
12
- finish: (transactionId) => native.finish(transactionId),
12
+ finish: (transactionId, consume) => native.finish(transactionId, consume === true),
13
13
  observe(onTransaction) {
14
14
  const sub = new react_native_1.NativeEventEmitter(react_native_1.NativeModules.RevnixStore).addListener("revnixStoreTransaction", (tx) => onTransaction(tx));
15
15
  return () => sub.remove();
package/dist/store.d.ts CHANGED
@@ -12,6 +12,11 @@ export interface StoreTransaction {
12
12
  expiresAt?: number;
13
13
  /** Apple only: the StoreKit 2 JWS. */
14
14
  signedTransactionInfo?: string;
15
+ /** Google only: already acknowledged in Play. */
16
+ acknowledged?: boolean;
17
+ /** Google only: purchase() answered with an earlier purchase Play reported
18
+ * as already owned, not a new charge. */
19
+ alreadyOwned?: boolean;
15
20
  }
16
21
  export interface StoreAdapter {
17
22
  /** Resolves null when the customer cancelled or the purchase is pending. */
@@ -19,7 +24,8 @@ export interface StoreAdapter {
19
24
  customerId: string;
20
25
  }): Promise<StoreTransaction | null>;
21
26
  restore(): Promise<StoreTransaction[]>;
22
- finish(transactionId: string): Promise<void>;
27
+ /** `consume`: Google only, consume instead of acknowledge. */
28
+ finish(transactionId: string, consume?: boolean): Promise<void>;
23
29
  /** Transactions arriving outside purchase(): renewals, other devices, Ask
24
30
  * to Buy, refunds. Returns an unsubscribe. */
25
31
  observe(onTransaction: (tx: StoreTransaction) => void): () => void;
package/dist/types.d.ts CHANGED
@@ -149,6 +149,9 @@ export interface RevnixConfig {
149
149
  * read. A null answer deletes a previously stored `gps_adid`; a
150
150
  * rejection changes nothing and reports `op: "advertisingId"`. */
151
151
  advertisingId?: () => Promise<string | null | undefined>;
152
+ /** Google Play product ids sold as consumables. A registered purchase of
153
+ * one is consumed instead of acknowledged so it can be bought again. */
154
+ consumableProductIds?: string[];
152
155
  }
153
156
  /** Device integrity evidence for `POST /v1/installs` and `/v1/attribution`.
154
157
  * iOS: an App Attest key id and its attestation (standard base64) over
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "1.8.0";
1
+ export declare const SDK_VERSION = "1.9.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.8.0";
7
+ exports.SDK_VERSION = "1.9.0";
package/ios/RevnixStore.m CHANGED
@@ -12,6 +12,7 @@ RCT_EXTERN_METHOD(restore:(RCTPromiseResolveBlock)resolve
12
12
  rejecter:(RCTPromiseRejectBlock)reject)
13
13
 
14
14
  RCT_EXTERN_METHOD(finish:(NSString *)transactionId
15
+ consume:(BOOL)consume
15
16
  resolver:(RCTPromiseResolveBlock)resolve
16
17
  rejecter:(RCTPromiseRejectBlock)reject)
17
18
 
@@ -20,7 +20,7 @@ class RevnixStore: RCTEventEmitter {
20
20
  override func startObserving() {
21
21
  updates?.cancel()
22
22
  updates = Task { [weak self] in
23
- for await update in Transaction.updates where update.unsafePayloadValue.revocationDate == nil {
23
+ for await update in Transaction.updates {
24
24
  self?.sendEvent(withName: "revnixStoreTransaction", body: RevnixStore.payload(update))
25
25
  }
26
26
  }
@@ -72,9 +72,10 @@ class RevnixStore: RCTEventEmitter {
72
72
  }
73
73
  }
74
74
 
75
- @objc(finish:resolver:rejecter:)
75
+ @objc(finish:consume:resolver:rejecter:)
76
76
  func finish(
77
77
  _ transactionId: String,
78
+ consume _: Bool,
78
79
  resolver resolve: @escaping RCTPromiseResolveBlock,
79
80
  rejecter reject: @escaping RCTPromiseRejectBlock
80
81
  ) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-revnix",
3
- "version": "1.8.0",
3
+ "version": "1.9.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": {
package/src/client.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ PurchaseAlreadyOwnedError,
2
3
  PurchaseBlockedError,
3
4
  RevnixAuthError,
4
5
  RevnixError,
@@ -193,6 +194,33 @@ const pendingKey = (p: RegisterPurchaseInput) =>
193
194
  const worthQueueing = (err: unknown): boolean =>
194
195
  !(err instanceof RevnixError) || err.isRetryable;
195
196
 
197
+ const ATTRIBUTION_QUERY_PARAMS = new Set([
198
+ "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "utm_id", "campaign_id",
199
+ "pid", "source", "af_channel", "medium", "c", "campaign", "term", "af_ad",
200
+ "content", "gclid", "gbraid", "wbraid", "ttclid", "sccid", "twclid",
201
+ "revnix_link", "web_id",
202
+ ]);
203
+
204
+ function queryParamName(pair: string): string {
205
+ const raw = pair.split("=")[0] ?? "";
206
+ try {
207
+ return decodeURIComponent(raw.replace(/\+/g, " ")).toLowerCase();
208
+ } catch {
209
+ return raw.toLowerCase();
210
+ }
211
+ }
212
+
213
+ function attributionOnlyUrl(rawUrl: string): string {
214
+ const url = rawUrl.trim();
215
+ const parts = /^([a-z][a-z0-9+.-]*:)(\/\/[^/?#]*)?([^?#]*)(?:\?([^#]*))?/i.exec(url);
216
+ if (!parts) return url.split(/[?#]/)[0] ?? "";
217
+ const [, scheme, authority = "", path, query = ""] = parts;
218
+ const kept = query
219
+ .split("&")
220
+ .filter((pair) => ATTRIBUTION_QUERY_PARAMS.has(queryParamName(pair)));
221
+ return `${scheme}${authority.replace(/^\/\/.*@/, "//")}${path}${kept.length > 0 ? `?${kept.join("&")}` : ""}`;
222
+ }
223
+
196
224
  const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
197
225
 
198
226
  // REV-199 (CTO-R2 M24): ±20% on every scheduled delay.
@@ -263,6 +291,7 @@ export class RevnixClient {
263
291
  private readonly onDeferredDeepLink?: RevnixConfig["onDeferredDeepLink"];
264
292
  private readonly onAttribution?: RevnixConfig["onAttribution"];
265
293
  private readonly deviceIntegrity?: RevnixConfig["deviceIntegrity"];
294
+ private readonly consumableProductIds: string[];
266
295
  private integrityRequest: {
267
296
  customerId: string;
268
297
  body: Promise<{ integrity?: IntegrityEvidence }>;
@@ -340,6 +369,7 @@ export class RevnixClient {
340
369
  this.onDeferredDeepLink = config.onDeferredDeepLink;
341
370
  this.onAttribution = config.onAttribution;
342
371
  this.deviceIntegrity = config.deviceIntegrity;
372
+ this.consumableProductIds = config.consumableProductIds ?? [];
343
373
  this.readYourWritesDelaysMs =
344
374
  config.readYourWritesDelaysMs ?? [250, 500, 1000, 2000];
345
375
  this.customerIdPromise = this.loadCustomerId(config.customerId);
@@ -351,11 +381,12 @@ export class RevnixClient {
351
381
  if (config.advertisingId) this.reportAdvertisingId(config.advertisingId);
352
382
  const store = getStoreAdapter();
353
383
  if (store) {
354
- this.stopStoreObserver = store.observe((tx) =>
384
+ this.stopStoreObserver = store.observe((tx) => {
385
+ if (tx.acknowledged && !this.isConsumable(tx)) return;
355
386
  void this.registerStoreTransaction(store, tx).catch((err) =>
356
387
  this.diag("storeObserver", err)
357
- )
358
- );
388
+ );
389
+ });
359
390
  }
360
391
  // REV-157: warm the placement cache at launch, so the first paywall of the
361
392
  // session can render offline. Fire-and-forget; a failed key just stays on
@@ -547,6 +578,12 @@ export class RevnixClient {
547
578
  const customerId = await this.customerIdPromise;
548
579
  const tx = await store.purchase(productId, { customerId });
549
580
  if (!tx) return null;
581
+ if (tx.alreadyOwned && this.isConsumable(tx)) {
582
+ await this.registerStoreTransaction(store, tx, opts);
583
+ throw new PurchaseAlreadyOwnedError(
584
+ `Revnix: an earlier purchase of ${productId} was still owned and has been registered; buy again`
585
+ );
586
+ }
550
587
  return this.registerStoreTransaction(store, tx, opts);
551
588
  }
552
589
 
@@ -566,6 +603,10 @@ export class RevnixClient {
566
603
  return restored;
567
604
  }
568
605
 
606
+ private isConsumable(tx: StoreTransaction): boolean {
607
+ return tx.store === "google" && this.consumableProductIds.includes(tx.productId);
608
+ }
609
+
569
610
  private requireStore(): StoreAdapter {
570
611
  const store = getStoreAdapter();
571
612
  if (!store) {
@@ -582,9 +623,9 @@ export class RevnixClient {
582
623
  opts?: Pick<RegisterPurchaseInput, "viewId" | "paywallId" | "placementKey">,
583
624
  finish = true
584
625
  ): Promise<RegisterPurchaseResult> {
585
- const finishTx = () =>
626
+ const finishTx = (consume = false) =>
586
627
  finish
587
- ? store.finish(tx.transactionId).catch((err) => this.diag("storeFinish", err))
628
+ ? store.finish(tx.transactionId, consume).catch((err) => this.diag("storeFinish", err))
588
629
  : undefined;
589
630
  try {
590
631
  const result = await this.registerPurchase({
@@ -597,7 +638,7 @@ export class RevnixClient {
597
638
  signedTransactionInfo: tx.signedTransactionInfo,
598
639
  ...opts,
599
640
  });
600
- await finishTx();
641
+ await finishTx(this.isConsumable(tx));
601
642
  return result;
602
643
  } catch (err) {
603
644
  if (!(err instanceof PurchaseBlockedError)) await finishTx();
@@ -1400,7 +1441,7 @@ export class RevnixClient {
1400
1441
  return;
1401
1442
  }
1402
1443
  void this.rememberDeepLink(url);
1403
- const extra = { url: url.slice(0, 1024) };
1444
+ const extra = { url: attributionOnlyUrl(url).slice(0, 1024) };
1404
1445
  const batch = this.launchBatch;
1405
1446
  if (batch === null) {
1406
1447
  this.fireImplicit("deeplink_open", extra);
package/src/errors.ts CHANGED
@@ -24,6 +24,16 @@ export class PurchaseBlockedError extends RevnixError {
24
24
  }
25
25
  }
26
26
 
27
+ /** purchase() found an earlier Google Play consumable of this product still
28
+ * owned. It was registered and consumed instead of charging again; ask the
29
+ * customer to buy again. */
30
+ export class PurchaseAlreadyOwnedError extends Error {
31
+ constructor(message: string) {
32
+ super(message);
33
+ this.name = "PurchaseAlreadyOwnedError";
34
+ }
35
+ }
36
+
27
37
  /** C7: the request didn't complete within the configured timeout (default
28
38
  * 10s). `fetch` has no default timeout on React Native / browsers, so without
29
39
  * this a slow or dead backend would hang the app until the OS TCP timeout. */
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ import type { RevnixConfig } from "./types.js";
4
4
 
5
5
  export { RevnixClient } from "./client.js";
6
6
  export {
7
+ PurchaseAlreadyOwnedError,
7
8
  PurchaseBlockedError,
8
9
  RevnixAuthError,
9
10
  RevnixError,
@@ -10,7 +10,7 @@ import type { IntegrityEvidence, RevnixConfig } from "../types.js";
10
10
  interface NativeRevnixStore {
11
11
  purchase(productId: string, customerId: string): Promise<StoreTransaction | null>;
12
12
  restore(): Promise<StoreTransaction[]>;
13
- finish(transactionId: string): Promise<void>;
13
+ finish(transactionId: string, consume: boolean): Promise<void>;
14
14
  deviceIntegrity?(customerId: string): Promise<IntegrityEvidence | null>;
15
15
  advertisingId?(): Promise<string | null>;
16
16
  }
@@ -28,7 +28,7 @@ export const RevnixStore: StoreAdapter = native
28
28
  ? {
29
29
  purchase: (productId, { customerId }) => native.purchase(productId, customerId),
30
30
  restore: () => native.restore(),
31
- finish: (transactionId) => native.finish(transactionId),
31
+ finish: (transactionId, consume) => native.finish(transactionId, consume === true),
32
32
  observe(onTransaction) {
33
33
  const sub = new NativeEventEmitter(NativeModules.RevnixStore).addListener(
34
34
  "revnixStoreTransaction",
package/src/store.ts CHANGED
@@ -13,6 +13,11 @@ export interface StoreTransaction {
13
13
  expiresAt?: number;
14
14
  /** Apple only: the StoreKit 2 JWS. */
15
15
  signedTransactionInfo?: string;
16
+ /** Google only: already acknowledged in Play. */
17
+ acknowledged?: boolean;
18
+ /** Google only: purchase() answered with an earlier purchase Play reported
19
+ * as already owned, not a new charge. */
20
+ alreadyOwned?: boolean;
16
21
  }
17
22
 
18
23
  export interface StoreAdapter {
@@ -22,7 +27,8 @@ export interface StoreAdapter {
22
27
  opts: { customerId: string }
23
28
  ): Promise<StoreTransaction | null>;
24
29
  restore(): Promise<StoreTransaction[]>;
25
- finish(transactionId: string): Promise<void>;
30
+ /** `consume`: Google only, consume instead of acknowledge. */
31
+ finish(transactionId: string, consume?: boolean): Promise<void>;
26
32
  /** Transactions arriving outside purchase(): renewals, other devices, Ask
27
33
  * to Buy, refunds. Returns an unsubscribe. */
28
34
  observe(onTransaction: (tx: StoreTransaction) => void): () => void;
package/src/types.ts CHANGED
@@ -161,6 +161,9 @@ export interface RevnixConfig {
161
161
  * read. A null answer deletes a previously stored `gps_adid`; a
162
162
  * rejection changes nothing and reports `op: "advertisingId"`. */
163
163
  advertisingId?: () => Promise<string | null | undefined>;
164
+ /** Google Play product ids sold as consumables. A registered purchase of
165
+ * one is consumed instead of acknowledged so it can be bought again. */
166
+ consumableProductIds?: string[];
164
167
  }
165
168
 
166
169
  /** Device integrity evidence for `POST /v1/installs` and `/v1/attribution`.
package/src/version.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  // L3: the single source of the SDK version inside src/. Must match
2
2
  // package.json "version" — version.test.ts fails the build when they drift
3
3
  // (they had: client.ts said 0.7.0 while the package shipped 0.9.0).
4
- export const SDK_VERSION = "1.8.0";
4
+ export const SDK_VERSION = "1.9.0";