@chipmobilesdk/rn-payment 0.0.0-stage → 0.2.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.
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Hooks over the engine.
3
+ *
4
+ * Each subscribes on mount and unsubscribes on unmount, so repeated mount/unmount
5
+ * leaves no duplicate subscription and no surviving listener (FR-006, PERF-002).
6
+ *
7
+ * Reads are synchronous: the engine holds entitlements in memory, so a hook never
8
+ * returns a loading placeholder for an entitlement it already knows (PERF-001).
9
+ */
10
+
11
+ import { useCallback, useEffect, useState } from 'react';
12
+ import { usePaymentEngine } from './PaymentProvider';
13
+ import type {
14
+ EntitlementKey,
15
+ FinalizeResult,
16
+ PaymentStatus,
17
+ PendingPurchasesState,
18
+ PurchaseOptions,
19
+ PurchaseResult,
20
+ TransactionRef,
21
+ } from '../config/types';
22
+ import type {
23
+ EntitlementMap,
24
+ EntitlementState,
25
+ } from '../entitlements/aggregate';
26
+ import type { PaymentError } from '../diagnostics/errors';
27
+ import type { MissingProduct, ProductInfo } from '../engine/products';
28
+ import type { RestoreOutcome, SyncOutcome } from '../engine/engine';
29
+
30
+ /**
31
+ * One entitlement, re-rendering only when that one changes.
32
+ *
33
+ * The returned `status` is `unknown` until the first sync completes. That is the
34
+ * point: an app must be able to tell "not yet known" from "not entitled" so a
35
+ * paying user never sees the unpaid experience flash (FR-047).
36
+ */
37
+ export function useEntitlement(key: EntitlementKey): EntitlementState {
38
+ const engine = usePaymentEngine();
39
+ const [state, setState] = useState<EntitlementState>(() =>
40
+ engine.getEntitlement(key),
41
+ );
42
+
43
+ useEffect(() => {
44
+ // Re-read on subscribe: the entitlement may have changed between the initial
45
+ // render and this effect.
46
+ setState(engine.getEntitlement(key));
47
+ return engine.subscribeEntitlements(map => {
48
+ const next = map[key];
49
+ if (next) {
50
+ setState(next);
51
+ }
52
+ });
53
+ }, [engine, key]);
54
+
55
+ return state;
56
+ }
57
+
58
+ /** Every entitlement at once, for a screen that shows overall plan state. */
59
+ export function useEntitlements(): EntitlementMap {
60
+ const engine = usePaymentEngine();
61
+ const [map, setMap] = useState<EntitlementMap>(() =>
62
+ engine.getAllEntitlements(),
63
+ );
64
+
65
+ useEffect(() => {
66
+ setMap(engine.getAllEntitlements());
67
+ return engine.subscribeEntitlements(setMap);
68
+ }, [engine]);
69
+
70
+ return map;
71
+ }
72
+
73
+ function narrowPending(
74
+ state: PendingPurchasesState,
75
+ keys: readonly string[] | null,
76
+ ): PendingPurchasesState {
77
+ return keys
78
+ ? { ...state, purchases: state.purchases.filter(p => keys.includes(p.productKey)) }
79
+ : state;
80
+ }
81
+
82
+ /**
83
+ * Pending purchases, in this session or an earlier one (FR-101). Added in 0.2.0.
84
+ *
85
+ * Pass product keys to narrow it to the products one screen sells. Read
86
+ * `status` before the list: under `unknown`, an empty list is not "nothing
87
+ * pending", and a Buy button shown on it could charge a user twice.
88
+ */
89
+ export function usePendingPurchases(
90
+ productKeys?: readonly string[],
91
+ ): PendingPurchasesState {
92
+ const engine = usePaymentEngine();
93
+ // Joined so a caller passing a literal array does not resubscribe each render.
94
+ const keyToken = productKeys ? productKeys.join('|') : null;
95
+ const [state, setState] = useState<PendingPurchasesState>(() =>
96
+ narrowPending(engine.getPendingPurchases(), productKeys ?? null),
97
+ );
98
+
99
+ useEffect(() => {
100
+ const keys = keyToken === null ? null : keyToken.split('|').filter(Boolean);
101
+ setState(narrowPending(engine.getPendingPurchases(), keys));
102
+ return engine.subscribePendingPurchases(next => setState(narrowPending(next, keys)));
103
+ }, [engine, keyToken]);
104
+
105
+ return state;
106
+ }
107
+
108
+ /** Package status, so an app can tell "cannot sell here" from "not paid" (FR-022). */
109
+ export function usePaymentStatus(): PaymentStatus {
110
+ const engine = usePaymentEngine();
111
+ const [status, setStatus] = useState<PaymentStatus>(() => engine.getStatus());
112
+
113
+ useEffect(() => {
114
+ setStatus(engine.getStatus());
115
+ return engine.subscribeStatus(setStatus);
116
+ }, [engine]);
117
+
118
+ return status;
119
+ }
120
+
121
+ export interface UsePurchaseResult {
122
+ purchase: (
123
+ productKey: EntitlementKey,
124
+ options?: PurchaseOptions,
125
+ ) => Promise<PurchaseResult>;
126
+ consume: (ref: TransactionRef) => Promise<FinalizeResult>;
127
+ isPurchasing: boolean;
128
+ /** The last outcome, so a screen can render cancellation differently from failure. */
129
+ lastResult: PurchaseResult | undefined;
130
+ }
131
+
132
+ /**
133
+ * Starting a purchase.
134
+ *
135
+ * `lastResult` is the full typed union rather than a boolean, because a screen has
136
+ * to treat `user_cancelled` differently from `store_error` — showing an error
137
+ * banner to someone who simply changed their mind is the most common paywall bug.
138
+ */
139
+ export function usePurchase(): UsePurchaseResult {
140
+ const engine = usePaymentEngine();
141
+ const [isPurchasing, setIsPurchasing] = useState(false);
142
+ const [lastResult, setLastResult] = useState<PurchaseResult | undefined>();
143
+
144
+ const purchase = useCallback(
145
+ async (
146
+ productKey: string,
147
+ options?: PurchaseOptions,
148
+ ): Promise<PurchaseResult> => {
149
+ setIsPurchasing(true);
150
+ try {
151
+ const result = await engine.purchase(productKey, options);
152
+ setLastResult(result);
153
+ return result;
154
+ } finally {
155
+ setIsPurchasing(false);
156
+ }
157
+ },
158
+ [engine],
159
+ );
160
+
161
+ const consume = useCallback(
162
+ (ref: TransactionRef): Promise<FinalizeResult> => engine.consume(ref),
163
+ [engine],
164
+ );
165
+
166
+ return { purchase, consume, isPurchasing, lastResult };
167
+ }
168
+
169
+ export interface UseProductsResult {
170
+ products: ProductInfo[];
171
+ /** Reported so a paywall explains an absent row instead of rendering a blank. */
172
+ missing: MissingProduct[];
173
+ isLoading: boolean;
174
+ error: PaymentError | undefined;
175
+ refresh: () => Promise<void>;
176
+ }
177
+
178
+ /**
179
+ * Store-supplied product information for a paywall.
180
+ *
181
+ * Every price here is already formatted by the store for the user's locale. An app
182
+ * must render `formattedPrice` and never build its own string from
183
+ * `priceAmountMicros` — that field exists for sorting and comparison only
184
+ * (FR-028).
185
+ */
186
+ export function useProducts(productKeys: readonly string[]): UseProductsResult {
187
+ const engine = usePaymentEngine();
188
+ const [products, setProducts] = useState<ProductInfo[]>([]);
189
+ const [missing, setMissing] = useState<MissingProduct[]>([]);
190
+ const [isLoading, setIsLoading] = useState(false);
191
+ const [error, setError] = useState<PaymentError | undefined>();
192
+
193
+ // Joined so the effect depends on the contents rather than the array identity:
194
+ // a caller passing a literal would otherwise re-query on every render.
195
+ const keyToken = productKeys.join('|');
196
+
197
+ const load = useCallback(
198
+ async (fresh: boolean): Promise<void> => {
199
+ const keys = keyToken === '' ? [] : keyToken.split('|');
200
+ if (keys.length === 0) {
201
+ setProducts([]);
202
+ setMissing([]);
203
+ return;
204
+ }
205
+ setIsLoading(true);
206
+ try {
207
+ const result = fresh
208
+ ? await engine.refreshProducts(keys)
209
+ : await engine.getProducts(keys);
210
+ if (result.ok) {
211
+ setProducts(result.products);
212
+ setMissing(result.missing);
213
+ setError(undefined);
214
+ } else {
215
+ setError(result.error);
216
+ }
217
+ } finally {
218
+ setIsLoading(false);
219
+ }
220
+ },
221
+ [engine, keyToken],
222
+ );
223
+
224
+ useEffect(() => {
225
+ load(false).catch(() => undefined);
226
+ }, [load]);
227
+
228
+ const refresh = useCallback(() => load(true), [load]);
229
+
230
+ return { products, missing, isLoading, error, refresh };
231
+ }
232
+
233
+ export interface UseSyncResult {
234
+ sync: () => Promise<SyncOutcome>;
235
+ restore: () => Promise<RestoreOutcome>;
236
+ isSyncing: boolean;
237
+ lastSyncedAt: number | undefined;
238
+ }
239
+
240
+ /**
241
+ * Sync and restore, for a "Restore purchases" control.
242
+ *
243
+ * `restore`'s outcome distinguishes "restored" from "nothing found", so the app
244
+ * can avoid telling a user their purchases came back when none existed (FR-061).
245
+ */
246
+ export function useSync(): UseSyncResult {
247
+ const engine = usePaymentEngine();
248
+ const [isSyncing, setIsSyncing] = useState(false);
249
+ const [lastSyncedAt, setLastSyncedAt] = useState<number | undefined>(() =>
250
+ engine.getLastSyncedAt(),
251
+ );
252
+
253
+ const sync = useCallback(async (): Promise<SyncOutcome> => {
254
+ setIsSyncing(true);
255
+ try {
256
+ return await engine.sync('manual');
257
+ } finally {
258
+ setIsSyncing(false);
259
+ setLastSyncedAt(engine.getLastSyncedAt());
260
+ }
261
+ }, [engine]);
262
+
263
+ const restore = useCallback(async (): Promise<RestoreOutcome> => {
264
+ setIsSyncing(true);
265
+ try {
266
+ return await engine.restore();
267
+ } finally {
268
+ setIsSyncing(false);
269
+ setLastSyncedAt(engine.getLastSyncedAt());
270
+ }
271
+ }, [engine]);
272
+
273
+ return { sync, restore, isSyncing, lastSyncedAt };
274
+ }
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Fake store — a full `ProviderPort` implementation for automated tests.
3
+ *
4
+ * Lives behind `@chipmobilesdk/rn-payment/testing` so test doubles cannot reach a
5
+ * production bundle.
6
+ *
7
+ * Nearly every requirement in this package concerns a rare ordering or failure
8
+ * path — a pending purchase settling two sessions later, an app killed between
9
+ * payment and acknowledgement, a billing connection dropping mid-sync. None of
10
+ * those are reachable on a real device on demand, so this is what makes them
11
+ * testable at all (NFR-007, SC-008).
12
+ */
13
+
14
+ import type {
15
+ ProviderCapabilities,
16
+ ProviderConnectOutcome,
17
+ ProviderFailureReason,
18
+ ProviderPort,
19
+ ProviderPriceChangeOutcome,
20
+ ProviderProduct,
21
+ ProviderPurchase,
22
+ ProviderPurchaseOutcome,
23
+ ProviderPurchaseRequest,
24
+ ProviderToken,
25
+ StoreProductId,
26
+ } from '../adapter/port';
27
+ import type { ProductType, Unsubscribe } from '../config/types';
28
+
29
+ export interface FakeStoreOptions {
30
+ /** Fails `connect` until `setConnectOutcome` says otherwise. */
31
+ connectOutcome?: ProviderConnectOutcome;
32
+ capabilities?: Partial<ProviderCapabilities>;
33
+ products?: ProviderProduct[];
34
+ owned?: ProviderPurchase[];
35
+ }
36
+
37
+ export interface FakeStore extends ProviderPort {
38
+ // --- Scripting the next purchase -------------------------------------------
39
+ /** Next `launchPurchase` succeeds and yields this purchase. */
40
+ scriptPurchaseSuccess(overrides?: Partial<ProviderPurchase>): void;
41
+ /** Next `launchPurchase` fails with this reason — cancellation included (FR-032). */
42
+ scriptPurchaseFailure(reason: ProviderFailureReason): void;
43
+ /** Next `launchPurchase` returns a purchase in the pending state (FR-037). */
44
+ scriptPurchasePending(overrides?: Partial<ProviderPurchase>): void;
45
+
46
+ // --- Store-side state ------------------------------------------------------
47
+ setOwned(purchases: ProviderPurchase[]): void;
48
+ /** Push a purchase through `observePurchases`, as a settling pending one does. */
49
+ pushPurchaseUpdate(purchase: ProviderPurchase): void;
50
+ /** Stop reporting a transaction as valid — a refund or revocation (FR-064). */
51
+ revoke(transactionId: string): void;
52
+
53
+ // --- Ending a pending purchase (0.2.0) -------------------------------------
54
+ // Each changes store-side state only, as the real store does: the engine sees
55
+ // it at its next sync, so it works the same across a simulated app kill. To
56
+ // deliver a settlement without a sync, `pushPurchaseUpdate` it as well.
57
+ /** The money arrives: the owned record turns `purchased` (FR-039). */
58
+ settlePending(transactionId: string): void;
59
+ /** The payment is declined or abandoned: the store stops reporting it (FR-040). */
60
+ cancelPending(transactionId: string): void;
61
+ /**
62
+ * The pending window runs out. Reported through `pendingEndReason`, which a
63
+ * store that knows implements — Play does not, so on device this is
64
+ * indistinguishable from `cancelPending` (FR-102).
65
+ */
66
+ expirePending(transactionId: string): void;
67
+ setProducts(products: ProviderProduct[]): void;
68
+
69
+ // --- Connection ------------------------------------------------------------
70
+ setConnectOutcome(outcome: ProviderConnectOutcome): void;
71
+ /** Drop the connection, as the billing service does (FR-020). */
72
+ dropConnection(reason?: string): void;
73
+ restoreConnection(): void;
74
+
75
+ // --- Finalization ----------------------------------------------------------
76
+ /** Make `acknowledge`/`consume` fail, to exercise bounded retry (FR-045). */
77
+ setFinalizeFailure(failing: boolean): void;
78
+
79
+ // --- Inspection ------------------------------------------------------------
80
+ acknowledged(): ProviderToken[];
81
+ consumed(): ProviderToken[];
82
+ connectCount(): number;
83
+ launchCount(): number;
84
+ queryOwnedCount(): number;
85
+ disconnectCount(): number;
86
+ subscriptionManagementOpenedFor(): (StoreProductId | undefined)[];
87
+ reset(): void;
88
+ }
89
+
90
+ const DEFAULT_CAPABILITIES: ProviderCapabilities = {
91
+ supportsSubscriptions: true,
92
+ supportsPlanChange: true,
93
+ supportsPriceChangeConfirmation: true,
94
+ // Matches Android reality: grace and hold are not separable client-side.
95
+ reportsGraceAndHoldDistinctly: false,
96
+ supportsExternalTransactions: false,
97
+ // The whole point of the fake: it moves no money, so the environment guard
98
+ // lets a test complete a purchase while still blocking the real adapter
99
+ // outside production (FR-014).
100
+ createsRealTransactions: false,
101
+ };
102
+
103
+ let sequence = 0;
104
+
105
+ /** A purchase with sensible defaults, so tests state only what they care about. */
106
+ export function makePurchase(
107
+ overrides: Partial<ProviderPurchase> = {},
108
+ ): ProviderPurchase {
109
+ sequence += 1;
110
+ return {
111
+ storeProductId: overrides.storeProductId ?? 'store.product.default',
112
+ transactionId: overrides.transactionId ?? `txn_${sequence}`,
113
+ token: overrides.token ?? `token_${sequence}`,
114
+ state: overrides.state ?? 'purchased',
115
+ purchasedAt: overrides.purchasedAt ?? 1_700_000_000_000,
116
+ isAcknowledged: overrides.isAcknowledged ?? false,
117
+ expiresAt: overrides.expiresAt,
118
+ willAutoRenew: overrides.willAutoRenew,
119
+ verificationPayload: overrides.verificationPayload ?? 'fake-verification',
120
+ };
121
+ }
122
+
123
+ export function createFakeStore(options: FakeStoreOptions = {}): FakeStore {
124
+ let connectOutcome: ProviderConnectOutcome = options.connectOutcome ?? {
125
+ ok: true,
126
+ };
127
+ let capabilities: ProviderCapabilities = {
128
+ ...DEFAULT_CAPABILITIES,
129
+ ...options.capabilities,
130
+ };
131
+ let products = [...(options.products ?? [])];
132
+ let owned = [...(options.owned ?? [])];
133
+
134
+ let scripted:
135
+ | { kind: 'success' | 'pending'; overrides?: Partial<ProviderPurchase> }
136
+ | { kind: 'failure'; reason: ProviderFailureReason }
137
+ | undefined;
138
+
139
+ let finalizeFails = false;
140
+ let connected = false;
141
+ /** Why each pending purchase ended, for the ones `expirePending` or `cancelPending` ended. */
142
+ const endReasons = new Map<string, 'cancelled' | 'expired'>();
143
+
144
+ const acknowledgedTokens: ProviderToken[] = [];
145
+ const consumedTokens: ProviderToken[] = [];
146
+ const managementOpenedFor: (StoreProductId | undefined)[] = [];
147
+ const connectionListeners = new Set<
148
+ (connected: boolean, reason?: string) => void
149
+ >();
150
+ const purchaseListeners = new Set<(purchase: ProviderPurchase) => void>();
151
+
152
+ let connectCalls = 0;
153
+ let launchCalls = 0;
154
+ let queryOwnedCalls = 0;
155
+ let disconnectCalls = 0;
156
+
157
+ const notifyConnection = (next: boolean, reason?: string): void => {
158
+ connected = next;
159
+ for (const listener of [...connectionListeners]) {
160
+ listener(next, reason);
161
+ }
162
+ };
163
+
164
+ return {
165
+ // --- ProviderPort ------------------------------------------------------
166
+ async connect(): Promise<ProviderConnectOutcome> {
167
+ connectCalls += 1;
168
+ if (connectOutcome.ok) {
169
+ notifyConnection(true);
170
+ }
171
+ return connectOutcome;
172
+ },
173
+
174
+ async disconnect(): Promise<void> {
175
+ disconnectCalls += 1;
176
+ if (connected) {
177
+ notifyConnection(false, 'disconnect');
178
+ }
179
+ },
180
+
181
+ observeConnection(listener): Unsubscribe {
182
+ connectionListeners.add(listener);
183
+ let removed = false;
184
+ return () => {
185
+ if (removed) {
186
+ return;
187
+ }
188
+ removed = true;
189
+ connectionListeners.delete(listener);
190
+ };
191
+ },
192
+
193
+ async queryProducts(
194
+ storeProductIds: StoreProductId[],
195
+ productType: ProductType,
196
+ ): Promise<ProviderProduct[]> {
197
+ return products.filter(
198
+ p =>
199
+ storeProductIds.includes(p.storeProductId) &&
200
+ p.productType === productType,
201
+ );
202
+ },
203
+
204
+ async queryOwned(): Promise<ProviderPurchase[]> {
205
+ queryOwnedCalls += 1;
206
+ // Pending purchases are returned flagged, never filtered out: the engine
207
+ // must see them to raise the pending signal while never granting.
208
+ return [...owned];
209
+ },
210
+
211
+ async launchPurchase(
212
+ request: ProviderPurchaseRequest,
213
+ ): Promise<ProviderPurchaseOutcome> {
214
+ launchCalls += 1;
215
+ const script = scripted;
216
+ scripted = undefined;
217
+
218
+ if (!script) {
219
+ return { ok: false, reason: 'store_error', detail: 'no script set' };
220
+ }
221
+ if (script.kind === 'failure') {
222
+ return { ok: false, reason: script.reason };
223
+ }
224
+
225
+ const purchase = makePurchase({
226
+ storeProductId: request.storeProductId,
227
+ state: script.kind === 'pending' ? 'pending' : 'purchased',
228
+ ...script.overrides,
229
+ });
230
+ owned = [...owned.filter(p => p.transactionId !== purchase.transactionId), purchase];
231
+ return { ok: true, purchase };
232
+ },
233
+
234
+ observePurchases(listener): Unsubscribe {
235
+ purchaseListeners.add(listener);
236
+ let removed = false;
237
+ return () => {
238
+ if (removed) {
239
+ return;
240
+ }
241
+ removed = true;
242
+ purchaseListeners.delete(listener);
243
+ };
244
+ },
245
+
246
+ async acknowledge(token: ProviderToken): Promise<void> {
247
+ if (finalizeFails) {
248
+ throw new Error('fake acknowledge failure');
249
+ }
250
+ // Safe to call for an already-acknowledged transaction (FR-044).
251
+ if (!acknowledgedTokens.includes(token)) {
252
+ acknowledgedTokens.push(token);
253
+ }
254
+ owned = owned.map(p =>
255
+ p.token === token ? { ...p, isAcknowledged: true } : p,
256
+ );
257
+ },
258
+
259
+ async consume(token: ProviderToken): Promise<void> {
260
+ if (finalizeFails) {
261
+ throw new Error('fake consume failure');
262
+ }
263
+ if (!consumedTokens.includes(token)) {
264
+ consumedTokens.push(token);
265
+ }
266
+ // A consumed purchase stops being owned, which is what lets the same
267
+ // product be bought again (FR-043).
268
+ owned = owned.filter(p => p.token !== token);
269
+ },
270
+
271
+ async openSubscriptionManagement(
272
+ storeProductId?: StoreProductId,
273
+ ): Promise<void> {
274
+ managementOpenedFor.push(storeProductId);
275
+ },
276
+
277
+ async confirmPriceChange(): Promise<ProviderPriceChangeOutcome> {
278
+ return { ok: true, confirmed: true };
279
+ },
280
+
281
+ capabilities: () => capabilities,
282
+
283
+ pendingEndReason: transactionId => endReasons.get(transactionId),
284
+
285
+ // --- Scripting ---------------------------------------------------------
286
+ scriptPurchaseSuccess(overrides) {
287
+ scripted = { kind: 'success', overrides };
288
+ },
289
+ scriptPurchaseFailure(reason) {
290
+ scripted = { kind: 'failure', reason };
291
+ },
292
+ scriptPurchasePending(overrides) {
293
+ scripted = { kind: 'pending', overrides };
294
+ },
295
+
296
+ // --- Store state -------------------------------------------------------
297
+ setOwned(purchases) {
298
+ owned = [...purchases];
299
+ },
300
+ pushPurchaseUpdate(purchase) {
301
+ owned = [
302
+ ...owned.filter(p => p.transactionId !== purchase.transactionId),
303
+ purchase,
304
+ ];
305
+ for (const listener of [...purchaseListeners]) {
306
+ listener(purchase);
307
+ }
308
+ },
309
+ revoke(transactionId) {
310
+ owned = owned.filter(p => p.transactionId !== transactionId);
311
+ },
312
+ settlePending(transactionId) {
313
+ owned = owned.map(p =>
314
+ p.transactionId === transactionId ? { ...p, state: 'purchased' } : p,
315
+ );
316
+ },
317
+ cancelPending(transactionId) {
318
+ owned = owned.filter(p => p.transactionId !== transactionId);
319
+ endReasons.set(transactionId, 'cancelled');
320
+ },
321
+ expirePending(transactionId) {
322
+ owned = owned.filter(p => p.transactionId !== transactionId);
323
+ endReasons.set(transactionId, 'expired');
324
+ },
325
+ setProducts(next) {
326
+ products = [...next];
327
+ },
328
+
329
+ // --- Connection --------------------------------------------------------
330
+ setConnectOutcome(outcome) {
331
+ connectOutcome = outcome;
332
+ },
333
+ dropConnection(reason = 'service_disconnected') {
334
+ notifyConnection(false, reason);
335
+ },
336
+ restoreConnection() {
337
+ notifyConnection(true);
338
+ },
339
+
340
+ // --- Finalization ------------------------------------------------------
341
+ setFinalizeFailure(failing) {
342
+ finalizeFails = failing;
343
+ },
344
+
345
+ // --- Inspection --------------------------------------------------------
346
+ acknowledged: () => [...acknowledgedTokens],
347
+ consumed: () => [...consumedTokens],
348
+ connectCount: () => connectCalls,
349
+ launchCount: () => launchCalls,
350
+ queryOwnedCount: () => queryOwnedCalls,
351
+ disconnectCount: () => disconnectCalls,
352
+ subscriptionManagementOpenedFor: () => [...managementOpenedFor],
353
+
354
+ reset() {
355
+ connectOutcome = { ok: true };
356
+ capabilities = { ...DEFAULT_CAPABILITIES, ...options.capabilities };
357
+ products = [];
358
+ owned = [];
359
+ scripted = undefined;
360
+ finalizeFails = false;
361
+ connected = false;
362
+ endReasons.clear();
363
+ acknowledgedTokens.length = 0;
364
+ consumedTokens.length = 0;
365
+ managementOpenedFor.length = 0;
366
+ connectionListeners.clear();
367
+ purchaseListeners.clear();
368
+ connectCalls = 0;
369
+ launchCalls = 0;
370
+ queryOwnedCalls = 0;
371
+ disconnectCalls = 0;
372
+ },
373
+ };
374
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `@chipmobilesdk/rn-payment/testing` — test doubles.
3
+ *
4
+ * A separate entrypoint so the fake store cannot reach a production bundle
5
+ * (LIB-003). Nothing here is part of the package's public runtime contract.
6
+ */
7
+
8
+ export { createFakeStore, makePurchase } from './fakeStore';
9
+ export type { FakeStore, FakeStoreOptions } from './fakeStore';
10
+ export { createMemorySnapshotStore } from '../persistence/mmkv-store';