@chipmobilesdk/rn-payment 0.4.1 → 0.6.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.
@@ -36,13 +36,23 @@
36
36
  * shortcut.
37
37
  */
38
38
 
39
+ import { createListenerSet, lazyPeer } from '@chipmobilesdk/rn-core';
40
+
39
41
  import type { RequestPurchaseProps, SubscriptionReplacementModeAndroid } from 'react-native-iap';
40
42
 
41
43
  import type { ProductType, Unsubscribe } from '../config/types';
44
+ import {
45
+ mapErrorCode,
46
+ normalizeProduct,
47
+ normalizePurchase,
48
+ type RawProduct,
49
+ type RawPurchase,
50
+ type RawPurchaseError,
51
+ } from './iapShapes';
52
+ import { PaymentError, redact } from '../diagnostics/errors';
42
53
  import type {
43
54
  ProviderCapabilities,
44
55
  ProviderConnectOutcome,
45
- ProviderFailureReason,
46
56
  ProviderPort,
47
57
  ProviderPriceChangeOutcome,
48
58
  ProviderProduct,
@@ -53,6 +63,10 @@ import type {
53
63
  StoreProductId,
54
64
  } from './port';
55
65
 
66
+ // Translation lives in `iapShapes`; it is published from here, where every
67
+ // consumer has always imported it from.
68
+ export { toProviderPurchaseState } from './iapShapes';
69
+
56
70
  /**
57
71
  * Exactly what this adapter needs from the binding.
58
72
  *
@@ -75,67 +89,6 @@ interface IapModule {
75
89
  deepLinkToSubscriptions?(params: { skuAndroid?: string }): Promise<void>;
76
90
  }
77
91
 
78
- interface RawProduct {
79
- id?: string;
80
- productId?: string;
81
- title?: string;
82
- name?: string;
83
- description?: string;
84
- displayPrice?: string;
85
- localizedPrice?: string;
86
- currency?: string;
87
- currencyCode?: string;
88
- price?: string | number;
89
- oneTimePurchaseOfferDetails?: {
90
- formattedPrice?: string;
91
- priceAmountMicros?: string | number;
92
- priceCurrencyCode?: string;
93
- };
94
- subscriptionOfferDetails?: RawSubscriptionOffer[];
95
- }
96
-
97
- interface RawSubscriptionOffer {
98
- offerToken?: string;
99
- offerId?: string;
100
- basePlanId?: string;
101
- pricingPhases?: {
102
- pricingPhaseList?: Array<{
103
- formattedPrice?: string;
104
- priceAmountMicros?: string | number;
105
- priceCurrencyCode?: string;
106
- billingPeriod?: string;
107
- recurrenceMode?: number;
108
- billingCycleCount?: number;
109
- }>;
110
- };
111
- }
112
-
113
- interface RawPurchase {
114
- id?: string;
115
- transactionId?: string;
116
- productId?: string;
117
- ids?: string[];
118
- purchaseToken?: string;
119
- purchaseStateAndroid?: number | null;
120
- /** A string in the 16.x binding: `'pending' | 'purchased' | 'unknown'`. */
121
- purchaseState?: number | string;
122
- transactionDate?: number;
123
- isAcknowledgedAndroid?: boolean;
124
- isAcknowledged?: boolean;
125
- autoRenewingAndroid?: boolean;
126
- isAutoRenewing?: boolean;
127
- expirationDateAndroid?: number;
128
- transactionReceipt?: string;
129
- dataAndroid?: string;
130
- }
131
-
132
- /** What `purchaseErrorListener` delivers. */
133
- interface RawPurchaseError {
134
- code?: string;
135
- message?: string;
136
- productId?: string | null;
137
- }
138
-
139
92
  /**
140
93
  * How long to wait for the outcome event after `requestPurchase` has resolved.
141
94
  * The binding emits the event before or as it resolves, so this only bounds the
@@ -190,162 +143,25 @@ export function toRequestProps(request: ProviderPurchaseRequest): RequestPurchas
190
143
  };
191
144
  }
192
145
 
193
- /** Play's `PurchaseState`: 0 unspecified, 1 purchased, 2 pending. */
194
- const PLAY_STATE_PURCHASED = 1;
195
- const PLAY_STATE_PENDING = 2;
196
-
197
- function loadModule(): IapModule {
198
- // Required lazily so importing the package does not require the native module.
199
- const mod = require('react-native-iap') as IapModule;
200
- return mod;
201
- }
202
-
203
- function toMicros(value: string | number | undefined): number {
204
- if (typeof value === 'number') {
205
- return value;
206
- }
207
- if (typeof value === 'string') {
208
- const parsed = Number.parseInt(value, 10);
209
- return Number.isFinite(parsed) ? parsed : 0;
146
+ const iapPeer = lazyPeer('react-native-iap', () => {
147
+ // Inside `try`, so Metro bundles an app that leaves this optional peer out.
148
+ try {
149
+ return require('react-native-iap') as IapModule;
150
+ } catch (cause) {
151
+ throw cause;
210
152
  }
211
- return 0;
212
- }
213
-
214
- function normalizeProduct(raw: RawProduct, productType: ProductType): ProviderProduct {
215
- const oneTime = raw.oneTimePurchaseOfferDetails;
216
- const currencyCode = oneTime?.priceCurrencyCode ?? raw.currencyCode ?? raw.currency ?? '';
153
+ });
217
154
 
218
- return {
219
- storeProductId: raw.id ?? raw.productId ?? '',
220
- productType,
221
- title: raw.title ?? raw.name ?? '',
222
- description: raw.description ?? '',
223
- // Store-formatted, never built here: only the store knows the right currency
224
- // symbol, separator, and placement for the user's locale (FR-028).
225
- formattedPrice: oneTime?.formattedPrice ?? raw.displayPrice ?? raw.localizedPrice ?? '',
226
- currencyCode,
227
- priceAmountMicros: toMicros(oneTime?.priceAmountMicros ?? raw.price),
228
- offers: (raw.subscriptionOfferDetails ?? []).map(offer => normalizeOffer(offer, currencyCode)),
229
- };
230
- }
231
-
232
- function normalizeOffer(
233
- offer: RawSubscriptionOffer,
234
- fallbackCurrency: string,
235
- ): ProviderProduct['offers'][number] {
236
- const phases = offer.pricingPhases?.pricingPhaseList ?? [];
237
- // The last phase is the recurring price; earlier ones are trial or
238
- // introductory. Play orders them that way.
239
- const recurring = phases[phases.length - 1];
240
- const intro = phases.length > 1 ? phases[0] : undefined;
241
- const isFreeTrial = intro !== undefined && toMicros(intro.priceAmountMicros) === 0;
242
-
243
- return {
244
- offerKey: offer.offerToken ?? offer.offerId ?? offer.basePlanId ?? '',
245
- billingPeriod: recurring?.billingPeriod ?? '',
246
- formattedPrice: recurring?.formattedPrice ?? '',
247
- priceAmountMicros: toMicros(recurring?.priceAmountMicros),
248
- currencyCode: recurring?.priceCurrencyCode ?? fallbackCurrency,
249
- trialPeriod: isFreeTrial ? intro?.billingPeriod : undefined,
250
- introductoryPrice:
251
- intro && !isFreeTrial
252
- ? {
253
- formattedPrice: intro.formattedPrice ?? '',
254
- period: intro.billingPeriod ?? '',
255
- cycles: intro.billingCycleCount ?? 1,
256
- }
257
- : undefined,
258
- // Play decides eligibility by only returning offers the user qualifies for,
259
- // so forwarding `true` reports the store's determination rather than
260
- // computing our own (FR-026).
261
- isEligible: true,
262
- };
263
- }
264
-
265
- /**
266
- * Reads the purchase state from whichever field the binding filled.
267
- *
268
- * Pending is preserved, never flattened to purchased: that distinction is what
269
- * stops an entitlement being granted for money that has not arrived (FR-037).
270
- * And an unrecognized or missing state is `unspecified`, never `purchased`.
271
- *
272
- * 0.1.0 read only Play's numeric state and treated anything else as purchased.
273
- * The 16.x binding hands JavaScript the *string* state and does not carry the
274
- * numeric one across, so on a device every pending purchase arrived as a string,
275
- * fell through to the default, and was granted. Unit tests could not see it: they
276
- * drive the fake store, which never passes through here.
277
- */
278
- export function toProviderPurchaseState(
279
- raw: Pick<RawPurchase, 'purchaseStateAndroid' | 'purchaseState'>,
280
- ): ProviderPurchase['state'] {
281
- let numeric: number | undefined;
282
- if (typeof raw.purchaseStateAndroid === 'number') {
283
- numeric = raw.purchaseStateAndroid;
284
- } else if (typeof raw.purchaseState === 'number') {
285
- numeric = raw.purchaseState;
286
- }
287
- if (numeric !== undefined) {
288
- if (numeric === PLAY_STATE_PENDING) {
289
- return 'pending';
290
- }
291
- return numeric === PLAY_STATE_PURCHASED ? 'purchased' : 'unspecified';
292
- }
293
-
294
- switch (typeof raw.purchaseState === 'string' ? raw.purchaseState.toLowerCase() : '') {
295
- case 'purchased':
296
- case 'restored':
297
- return 'purchased';
298
- case 'pending':
299
- case 'deferred':
300
- return 'pending';
301
- default:
302
- return 'unspecified';
303
- }
304
- }
305
-
306
- function normalizePurchase(raw: RawPurchase): ProviderPurchase {
307
- return {
308
- storeProductId: raw.productId ?? raw.ids?.[0] ?? '',
309
- transactionId: raw.id ?? raw.transactionId ?? raw.purchaseToken ?? '',
310
- token: raw.purchaseToken ?? '',
311
- state: toProviderPurchaseState(raw),
312
- purchasedAt: raw.transactionDate ?? Date.now(),
313
- isAcknowledged: raw.isAcknowledgedAndroid ?? raw.isAcknowledged ?? false,
314
- expiresAt: raw.expirationDateAndroid,
315
- willAutoRenew: raw.autoRenewingAndroid ?? raw.isAutoRenewing,
316
- // Opaque material for the app's backend. Carried here and handed to the
317
- // validation hook only — never logged or put in an event (FR-074).
318
- verificationPayload: raw.transactionReceipt ?? raw.dataAndroid,
319
- };
320
- }
321
-
322
- /** Maps a binding error code onto the port's vocabulary, so no store code escapes. */
323
- function mapErrorCode(code: string | undefined): ProviderFailureReason {
324
- switch (code) {
325
- case 'E_USER_CANCELLED':
326
- case 'user-cancelled':
327
- return 'user_cancelled';
328
- case 'E_ALREADY_OWNED':
329
- case 'already-owned':
330
- case 'item-already-owned':
331
- return 'already_owned';
332
- case 'E_ITEM_UNAVAILABLE':
333
- case 'item-unavailable':
334
- case 'sku-not-found':
335
- return 'unknown_product';
336
- case 'E_NOT_PREPARED':
337
- case 'not-prepared':
338
- return 'not_ready';
339
- case 'E_NETWORK_ERROR':
340
- case 'network-error':
341
- return 'network_error';
342
- case 'E_DEFERRED_PAYMENT':
343
- // Not a failure — a pending purchase. The caller resolves it from the
344
- // purchase update rather than from this error.
345
- return 'store_error';
346
- default:
347
- return 'store_error';
348
- }
155
+ function loadModule(): IapModule {
156
+ return iapPeer.require(
157
+ (peer, cause) =>
158
+ new PaymentError({
159
+ code: 'PAYMENT_UNAVAILABLE',
160
+ message: `${peer} is not installed or its native module is not linked`,
161
+ operation: 'initialize',
162
+ cause: redact(cause),
163
+ }),
164
+ );
349
165
  }
350
166
 
351
167
  const ANDROID_CAPABILITIES: ProviderCapabilities = {
@@ -380,10 +196,10 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
380
196
  /** Tracked here, never read from the binding — see the openiap#408 note above. */
381
197
  let connected = false;
382
198
 
383
- const connectionListeners = new Set<(connected: boolean, reason?: string) => void>();
384
- const purchaseListeners = new Set<(purchase: ProviderPurchase) => void>();
199
+ const connectionListeners = createListenerSet<[boolean, string | undefined]>();
200
+ const purchaseListeners = createListenerSet<[ProviderPurchase]>();
385
201
  /** Only an in-flight `launchPurchase` listens here; see there. */
386
- const purchaseErrorListeners = new Set<(error: RawPurchaseError) => void>();
202
+ const purchaseErrorListeners = createListenerSet<[RawPurchaseError]>();
387
203
  let updateSub: { remove(): void } | undefined;
388
204
  let errorSub: { remove(): void } | undefined;
389
205
 
@@ -399,9 +215,7 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
399
215
  return;
400
216
  }
401
217
  connected = next;
402
- for (const listener of connectionListeners) {
403
- listener(next, reason);
404
- }
218
+ connectionListeners.notify(next, reason);
405
219
  };
406
220
 
407
221
  const attachListeners = (): void => {
@@ -411,10 +225,7 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
411
225
  detachListeners();
412
226
  const mod = module();
413
227
  updateSub = mod.purchaseUpdatedListener(raw => {
414
- const purchase = normalizePurchase(raw);
415
- for (const listener of purchaseListeners) {
416
- listener(purchase);
417
- }
228
+ purchaseListeners.notify(normalizePurchase(raw));
418
229
  });
419
230
  errorSub = mod.purchaseErrorListener(error => {
420
231
  // A `not-prepared` error is the one signal the binding gives that the
@@ -423,9 +234,7 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
423
234
  if (mapErrorCode(error.code) === 'not_ready') {
424
235
  notifyConnection(false, 'not_prepared');
425
236
  }
426
- for (const listener of purchaseErrorListeners) {
427
- listener(error);
428
- }
237
+ purchaseErrorListeners.notify(error);
429
238
  });
430
239
  };
431
240
 
@@ -486,15 +295,7 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
486
295
  },
487
296
 
488
297
  observeConnection(listener): Unsubscribe {
489
- connectionListeners.add(listener);
490
- let removed = false;
491
- return () => {
492
- if (removed) {
493
- return;
494
- }
495
- removed = true;
496
- connectionListeners.delete(listener);
497
- };
298
+ return connectionListeners.add(listener);
498
299
  },
499
300
 
500
301
  async queryProducts(
@@ -551,13 +352,13 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
551
352
  }
552
353
  settled = true;
553
354
  clearTimeout(grace);
554
- purchaseListeners.delete(onPurchase);
555
- purchaseErrorListeners.delete(onError);
355
+ stopPurchase();
356
+ stopError();
556
357
  resolve(outcome);
557
358
  }
558
359
 
559
- purchaseListeners.add(onPurchase);
560
- purchaseErrorListeners.add(onError);
360
+ const stopPurchase = purchaseListeners.add(onPurchase);
361
+ const stopError = purchaseErrorListeners.add(onError);
561
362
  try {
562
363
  module()
563
364
  .requestPurchase(toRequestProps(request))
@@ -581,15 +382,7 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
581
382
  },
582
383
 
583
384
  observePurchases(listener): Unsubscribe {
584
- purchaseListeners.add(listener);
585
- let removed = false;
586
- return () => {
587
- if (removed) {
588
- return;
589
- }
590
- removed = true;
591
- purchaseListeners.delete(listener);
592
- };
385
+ return purchaseListeners.add(listener);
593
386
  },
594
387
 
595
388
  async acknowledge(token: ProviderToken): Promise<void> {
@@ -0,0 +1,232 @@
1
+ /**
2
+ * The binding's shapes, and their translation into the port's vocabulary
3
+ * (PAY-12).
4
+ *
5
+ * The binding speaks Google Play: prices as micros in strings, offer tokens,
6
+ * numeric purchase states, store error codes. The port speaks this package's
7
+ * own. Keeping the translation here, with no I/O, no listeners and no module
8
+ * loading, is what makes it checkable against recorded payloads — and leaves
9
+ * the adapter next door about the calls it actually makes.
10
+ *
11
+ * The raw types are declared narrowly on purpose: they document the coupling,
12
+ * and a binding upgrade that changes one of these shapes fails to typecheck
13
+ * here rather than failing silently on a user's device.
14
+ */
15
+ import type { ProductType } from '../config/types';
16
+ import type { ProviderFailureReason, ProviderProduct, ProviderPurchase } from './port';
17
+
18
+ export interface RawProduct {
19
+ id?: string;
20
+ productId?: string;
21
+ title?: string;
22
+ name?: string;
23
+ description?: string;
24
+ displayPrice?: string;
25
+ localizedPrice?: string;
26
+ currency?: string;
27
+ currencyCode?: string;
28
+ price?: string | number;
29
+ oneTimePurchaseOfferDetails?: {
30
+ formattedPrice?: string;
31
+ priceAmountMicros?: string | number;
32
+ priceCurrencyCode?: string;
33
+ };
34
+ subscriptionOfferDetails?: RawSubscriptionOffer[];
35
+ }
36
+
37
+ export interface RawSubscriptionOffer {
38
+ offerToken?: string;
39
+ offerId?: string;
40
+ basePlanId?: string;
41
+ pricingPhases?: {
42
+ pricingPhaseList?: Array<{
43
+ formattedPrice?: string;
44
+ priceAmountMicros?: string | number;
45
+ priceCurrencyCode?: string;
46
+ billingPeriod?: string;
47
+ recurrenceMode?: number;
48
+ billingCycleCount?: number;
49
+ }>;
50
+ };
51
+ }
52
+
53
+ export interface RawPurchase {
54
+ id?: string;
55
+ transactionId?: string;
56
+ productId?: string;
57
+ ids?: string[];
58
+ purchaseToken?: string;
59
+ purchaseStateAndroid?: number | null;
60
+ /** A string in the 16.x binding: `'pending' | 'purchased' | 'unknown'`. */
61
+ purchaseState?: number | string;
62
+ transactionDate?: number;
63
+ isAcknowledgedAndroid?: boolean;
64
+ isAcknowledged?: boolean;
65
+ autoRenewingAndroid?: boolean;
66
+ isAutoRenewing?: boolean;
67
+ expirationDateAndroid?: number;
68
+ transactionReceipt?: string;
69
+ dataAndroid?: string;
70
+ }
71
+
72
+ /** What `purchaseErrorListener` delivers. */
73
+ export interface RawPurchaseError {
74
+ code?: string;
75
+ message?: string;
76
+ productId?: string | null;
77
+ }
78
+
79
+ /** Play's `PurchaseState`: 0 unspecified, 1 purchased, 2 pending. */
80
+ const PLAY_STATE_PURCHASED = 1;
81
+ const PLAY_STATE_PENDING = 2;
82
+
83
+ // Required lazily (rn-core's lazy peer) so importing the package does not require
84
+ // the native module.
85
+
86
+ export function toMicros(value: string | number | undefined): number {
87
+ if (typeof value === 'number') {
88
+ return value;
89
+ }
90
+ if (typeof value === 'string') {
91
+ const parsed = Number.parseInt(value, 10);
92
+ return Number.isFinite(parsed) ? parsed : 0;
93
+ }
94
+ return 0;
95
+ }
96
+
97
+ export function normalizeProduct(raw: RawProduct, productType: ProductType): ProviderProduct {
98
+ const oneTime = raw.oneTimePurchaseOfferDetails;
99
+ const currencyCode = oneTime?.priceCurrencyCode ?? raw.currencyCode ?? raw.currency ?? '';
100
+
101
+ return {
102
+ storeProductId: raw.id ?? raw.productId ?? '',
103
+ productType,
104
+ title: raw.title ?? raw.name ?? '',
105
+ description: raw.description ?? '',
106
+ // Store-formatted, never built here: only the store knows the right currency
107
+ // symbol, separator, and placement for the user's locale (FR-028).
108
+ formattedPrice: oneTime?.formattedPrice ?? raw.displayPrice ?? raw.localizedPrice ?? '',
109
+ currencyCode,
110
+ priceAmountMicros: toMicros(oneTime?.priceAmountMicros ?? raw.price),
111
+ offers: (raw.subscriptionOfferDetails ?? []).map(offer => normalizeOffer(offer, currencyCode)),
112
+ };
113
+ }
114
+
115
+ export function normalizeOffer(
116
+ offer: RawSubscriptionOffer,
117
+ fallbackCurrency: string,
118
+ ): ProviderProduct['offers'][number] {
119
+ const phases = offer.pricingPhases?.pricingPhaseList ?? [];
120
+ // The last phase is the recurring price; earlier ones are trial or
121
+ // introductory. Play orders them that way.
122
+ const recurring = phases[phases.length - 1];
123
+ const intro = phases.length > 1 ? phases[0] : undefined;
124
+ const isFreeTrial = intro !== undefined && toMicros(intro.priceAmountMicros) === 0;
125
+
126
+ return {
127
+ offerKey: offer.offerToken ?? offer.offerId ?? offer.basePlanId ?? '',
128
+ billingPeriod: recurring?.billingPeriod ?? '',
129
+ formattedPrice: recurring?.formattedPrice ?? '',
130
+ priceAmountMicros: toMicros(recurring?.priceAmountMicros),
131
+ currencyCode: recurring?.priceCurrencyCode ?? fallbackCurrency,
132
+ trialPeriod: isFreeTrial ? intro?.billingPeriod : undefined,
133
+ introductoryPrice:
134
+ intro && !isFreeTrial
135
+ ? {
136
+ formattedPrice: intro.formattedPrice ?? '',
137
+ period: intro.billingPeriod ?? '',
138
+ cycles: intro.billingCycleCount ?? 1,
139
+ }
140
+ : undefined,
141
+ // Play decides eligibility by only returning offers the user qualifies for,
142
+ // so forwarding `true` reports the store's determination rather than
143
+ // computing our own (FR-026).
144
+ isEligible: true,
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Reads the purchase state from whichever field the binding filled.
150
+ *
151
+ * Pending is preserved, never flattened to purchased: that distinction is what
152
+ * stops an entitlement being granted for money that has not arrived (FR-037).
153
+ * And an unrecognized or missing state is `unspecified`, never `purchased`.
154
+ *
155
+ * 0.1.0 read only Play's numeric state and treated anything else as purchased.
156
+ * The 16.x binding hands JavaScript the *string* state and does not carry the
157
+ * numeric one across, so on a device every pending purchase arrived as a string,
158
+ * fell through to the default, and was granted. Unit tests could not see it: they
159
+ * drive the fake store, which never passes through here.
160
+ */
161
+ export function toProviderPurchaseState(
162
+ raw: Pick<RawPurchase, 'purchaseStateAndroid' | 'purchaseState'>,
163
+ ): ProviderPurchase['state'] {
164
+ let numeric: number | undefined;
165
+ if (typeof raw.purchaseStateAndroid === 'number') {
166
+ numeric = raw.purchaseStateAndroid;
167
+ } else if (typeof raw.purchaseState === 'number') {
168
+ numeric = raw.purchaseState;
169
+ }
170
+ if (numeric !== undefined) {
171
+ if (numeric === PLAY_STATE_PENDING) {
172
+ return 'pending';
173
+ }
174
+ return numeric === PLAY_STATE_PURCHASED ? 'purchased' : 'unspecified';
175
+ }
176
+
177
+ switch (typeof raw.purchaseState === 'string' ? raw.purchaseState.toLowerCase() : '') {
178
+ case 'purchased':
179
+ case 'restored':
180
+ return 'purchased';
181
+ case 'pending':
182
+ case 'deferred':
183
+ return 'pending';
184
+ default:
185
+ return 'unspecified';
186
+ }
187
+ }
188
+
189
+ export function normalizePurchase(raw: RawPurchase): ProviderPurchase {
190
+ return {
191
+ storeProductId: raw.productId ?? raw.ids?.[0] ?? '',
192
+ transactionId: raw.id ?? raw.transactionId ?? raw.purchaseToken ?? '',
193
+ token: raw.purchaseToken ?? '',
194
+ state: toProviderPurchaseState(raw),
195
+ purchasedAt: raw.transactionDate ?? Date.now(),
196
+ isAcknowledged: raw.isAcknowledgedAndroid ?? raw.isAcknowledged ?? false,
197
+ expiresAt: raw.expirationDateAndroid,
198
+ willAutoRenew: raw.autoRenewingAndroid ?? raw.isAutoRenewing,
199
+ // Opaque material for the app's backend. Carried here and handed to the
200
+ // validation hook only — never logged or put in an event (FR-074).
201
+ verificationPayload: raw.transactionReceipt ?? raw.dataAndroid,
202
+ };
203
+ }
204
+
205
+ /** Maps a binding error code onto the port's vocabulary, so no store code escapes. */
206
+ export function mapErrorCode(code: string | undefined): ProviderFailureReason {
207
+ switch (code) {
208
+ case 'E_USER_CANCELLED':
209
+ case 'user-cancelled':
210
+ return 'user_cancelled';
211
+ case 'E_ALREADY_OWNED':
212
+ case 'already-owned':
213
+ case 'item-already-owned':
214
+ return 'already_owned';
215
+ case 'E_ITEM_UNAVAILABLE':
216
+ case 'item-unavailable':
217
+ case 'sku-not-found':
218
+ return 'unknown_product';
219
+ case 'E_NOT_PREPARED':
220
+ case 'not-prepared':
221
+ return 'not_ready';
222
+ case 'E_NETWORK_ERROR':
223
+ case 'network-error':
224
+ return 'network_error';
225
+ case 'E_DEFERRED_PAYMENT':
226
+ // Not a failure — a pending purchase. The caller resolves it from the
227
+ // purchase update rather than from this error.
228
+ return 'store_error';
229
+ default:
230
+ return 'store_error';
231
+ }
232
+ }
@@ -130,6 +130,14 @@ export interface PaymentConfig {
130
130
  * proceed (FR-015).
131
131
  */
132
132
  isProductionBuild: boolean;
133
+ /**
134
+ * Lets a non-production build complete purchases through the real store, so a
135
+ * Play license tester can buy on a debug build (PAY-16). Accepted only with
136
+ * `environment: 'test'`; any other environment rejects it at startup. License
137
+ * testers are not charged; any other account on the device would be, so keep
138
+ * it out of builds that reach real users.
139
+ */
140
+ allowStoreTestPurchases?: boolean;
133
141
  /**
134
142
  * Global sell switch. When false, purchases report `disabled` but entitlements
135
143
  * already held stay readable — turning off selling is not the same as taking
@@ -199,6 +199,16 @@ export function validateConfig(
199
199
  * continuing silently is exactly the failure this package exists to prevent.
200
200
  */
201
201
  export function assertEnvironmentSafe(config: PaymentConfig): void {
202
+ if (config.allowStoreTestPurchases === true && config.environment !== 'test') {
203
+ throw new PaymentConfigError({
204
+ code: 'PAYMENT_INVALID_CONFIG',
205
+ message:
206
+ `allowStoreTestPurchases is only accepted with environment "test", not ` +
207
+ `"${config.environment}": it exists for license testers, not for real users.`,
208
+ operation: 'configure',
209
+ offenders: ['allowStoreTestPurchases'],
210
+ });
211
+ }
202
212
  if (config.environment === 'production' && !config.isProductionBuild) {
203
213
  throw new PaymentConfigError({
204
214
  code: 'PAYMENT_PRODUCTION_CONFIG_IN_NON_PRODUCTION_BUILD',
@@ -213,12 +223,17 @@ export function assertEnvironmentSafe(config: PaymentConfig): void {
213
223
  }
214
224
 
215
225
  /**
216
- * Whether this configuration may hand a real purchase to a real store (FR-014).
226
+ * Whether this configuration may hand a purchase to a real store (FR-014).
217
227
  *
218
- * False outside production regardless of what else is configured. The engine
219
- * records each refusal in observable status so a test can assert the block
220
- * happened — the difference between a guarantee and a hope.
228
+ * True in a production build configured for production, and in a `test`
229
+ * environment that opted in with `allowStoreTestPurchases` (PAY-16). False
230
+ * everywhere else. The engine records each refusal in observable status so a
231
+ * test can assert the block happened — the difference between a guarantee and a
232
+ * hope.
221
233
  */
222
234
  export function mayCreateRealTransaction(config: PaymentConfig): boolean {
235
+ if (config.environment === 'test' && config.allowStoreTestPurchases === true) {
236
+ return true;
237
+ }
223
238
  return config.environment === 'production' && config.isProductionBuild;
224
239
  }