react-native-revnix 1.7.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
 
@@ -191,6 +191,11 @@ function localizeBlockDoc(doc, locale) {
191
191
  ...doc,
192
192
  defaultLocale: chain[0],
193
193
  blocks: doc.blocks.map((b) => localizeBlock(b, lookup)),
194
+ ...(doc.screens
195
+ ? {
196
+ screens: doc.screens.map((screen) => screen.map((b) => localizeBlock(b, lookup))),
197
+ }
198
+ : {}),
194
199
  };
195
200
  }
196
201
  const LINK_LABEL_ALIASES = {
@@ -112,12 +112,14 @@ export interface BlockStyle {
112
112
  /** REV-262: draw a block only while its plan card is (or is not) the selected
113
113
  * one. Ignored outside a package card — a root block is always drawn. */
114
114
  export type BlockVisibility = "selected" | "unselected";
115
- export type BlockAction = "close";
115
+ /** `close` dismisses the paywall; `next`/`back` move between onboarding
116
+ * screens and only appear inside `screens`. */
117
+ export type BlockAction = "close" | "next" | "back";
116
118
  export interface TextBlock {
117
119
  id: string;
118
120
  type: "text";
119
121
  text: string;
120
- /** Tapping this block dismisses the paywall. See BlockAction. */
122
+ /** What tapping this block does. See BlockAction. */
121
123
  action?: BlockAction;
122
124
  style?: BlockStyle;
123
125
  /** REV-262: merged over `style`, field by field, while the block sits in a
@@ -134,7 +136,7 @@ export interface ImageBlock {
134
136
  shape?: "rect" | "circle";
135
137
  fit?: "cover" | "contain";
136
138
  placeholder?: string;
137
- /** Tapping this block dismisses the paywall. See BlockAction. */
139
+ /** What tapping this block does. See BlockAction. */
138
140
  action?: BlockAction;
139
141
  style?: BlockStyle;
140
142
  /** REV-262: merged over `style`, field by field, while the block sits in a
@@ -182,8 +184,9 @@ export interface ButtonBlock {
182
184
  id: string;
183
185
  type: "button";
184
186
  label: string;
185
- /** `close` turns this button into a dismiss ("Not now") instead of the
186
- * purchase CTA, which is what a button means by default. */
187
+ /** `close` turns this button into a dismiss ("Not now"), and `next`/`back`
188
+ * into onboarding navigation, instead of the purchase CTA, which is what a
189
+ * button means by default. */
187
190
  action?: BlockAction;
188
191
  style?: BlockStyle;
189
192
  /** REV-262: merged over `style`, field by field, while the block sits in a
@@ -311,6 +314,10 @@ export interface PaywallBlockDoc {
311
314
  accentInk: string;
312
315
  fontFamily?: string;
313
316
  blocks: PaywallBlock[];
317
+ /** Onboarding: intro screens shown in order before `blocks`, which stays
318
+ * the last (purchase) screen. They share the doc's palette, layout and
319
+ * locale table. Absent or empty = `blocks` alone. */
320
+ screens?: PaywallBlock[][];
314
321
  /** REV-271: the language the blocks' own copy is written in. Absent = "en".
315
322
  * Never a key in a stored doc's `locales` — the tree IS that language's
316
323
  * version. A localized copy names the language it was localized to. */
@@ -341,3 +348,11 @@ export declare const DESIGN_CANVAS: {
341
348
  * bug this feature exists to fix. Two close buttons is merely ugly, so the
342
349
  * tie breaks toward always having one. */
343
350
  export declare function hasCloseAction(blocks: PaywallBlock[]): boolean;
351
+ export declare function onboardingScreen(doc: PaywallBlockDoc, index: number): {
352
+ blocks: PaywallBlock[];
353
+ dots?: {
354
+ count: number;
355
+ active: number;
356
+ };
357
+ };
358
+ export declare function stepScreen(doc: PaywallBlockDoc, index: number, action: BlockAction | undefined): number | undefined;
@@ -13,6 +13,8 @@
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.DESIGN_CANVAS = void 0;
15
15
  exports.hasCloseAction = hasCloseAction;
16
+ exports.onboardingScreen = onboardingScreen;
17
+ exports.stepScreen = stepScreen;
16
18
  /** The device screen `canvas` designs are authored against. */
17
19
  exports.DESIGN_CANVAS = { width: 393, height: 852 };
18
20
  /** Does this tree author a dismiss affordance that is CERTAIN to render?
@@ -43,3 +45,21 @@ function hasCloseAction(blocks) {
43
45
  : (b.type === "text" || b.type === "image" || b.type === "button") &&
44
46
  b.action === "close");
45
47
  }
48
+ function onboardingScreen(doc, index) {
49
+ const intro = doc.screens?.[index];
50
+ if (intro === undefined)
51
+ return { blocks: doc.blocks };
52
+ return {
53
+ blocks: intro,
54
+ dots: { count: doc.screens.length + 1, active: index },
55
+ };
56
+ }
57
+ function stepScreen(doc, index, action) {
58
+ if (index >= (doc.screens?.length ?? 0))
59
+ return undefined;
60
+ if (action === "next")
61
+ return index + 1;
62
+ if (action === "back" && index > 0)
63
+ return index - 1;
64
+ return undefined;
65
+ }
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. */
@@ -183,6 +183,11 @@ export function localizeBlockDoc(doc, locale) {
183
183
  ...doc,
184
184
  defaultLocale: chain[0],
185
185
  blocks: doc.blocks.map((b) => localizeBlock(b, lookup)),
186
+ ...(doc.screens
187
+ ? {
188
+ screens: doc.screens.map((screen) => screen.map((b) => localizeBlock(b, lookup))),
189
+ }
190
+ : {}),
186
191
  };
187
192
  }
188
193
  const LINK_LABEL_ALIASES = {
@@ -39,3 +39,21 @@ export function hasCloseAction(blocks) {
39
39
  : (b.type === "text" || b.type === "image" || b.type === "button") &&
40
40
  b.action === "close");
41
41
  }
42
+ export function onboardingScreen(doc, index) {
43
+ const intro = doc.screens?.[index];
44
+ if (intro === undefined)
45
+ return { blocks: doc.blocks };
46
+ return {
47
+ blocks: intro,
48
+ dots: { count: doc.screens.length + 1, active: index },
49
+ };
50
+ }
51
+ export function stepScreen(doc, index, action) {
52
+ if (index >= (doc.screens?.length ?? 0))
53
+ return undefined;
54
+ if (action === "next")
55
+ return index + 1;
56
+ if (action === "back" && index > 0)
57
+ return index - 1;
58
+ return undefined;
59
+ }
@@ -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();