@chipmobilesdk/rn-payment 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +482 -334
- package/README.md +925 -897
- package/package.json +52 -54
- package/skills/sdk-rn-payment/SKILL.md +175 -174
- package/src/adapter/createStoreProvider.ts +116 -118
- package/src/adapter/iap-adapter.ts +627 -660
- package/src/adapter/port.ts +209 -215
- package/src/config/types.ts +298 -310
- package/src/config/validate.ts +224 -232
- package/src/core/mutex.ts +25 -128
- package/src/core/retry.ts +95 -133
- package/src/core/timers.ts +272 -267
- package/src/diagnostics/errors.ts +124 -117
- package/src/diagnostics/events.ts +282 -266
- package/src/diagnostics/messageKeys.ts +93 -95
- package/src/engine/engine.ts +921 -999
- package/src/engine/finalize.ts +343 -346
- package/src/engine/foreground.ts +29 -0
- package/src/engine/products.ts +259 -259
- package/src/engine/purchase.ts +589 -614
- package/src/engine/subscriptions.ts +146 -157
- package/src/entitlements/aggregate.ts +261 -242
- package/src/entitlements/mapping.ts +67 -67
- package/src/entitlements/policy.ts +121 -129
- package/src/index.ts +5 -9
- package/src/persistence/mmkv-store.ts +158 -158
- package/src/persistence/snapshot.ts +220 -247
- package/src/react/PaymentProvider.tsx +90 -79
- package/src/react/hooks.ts +236 -274
- package/src/testing/conformance.ts +169 -0
- package/src/testing/fakeStore.ts +360 -393
- package/src/testing/index.ts +19 -10
package/src/react/hooks.ts
CHANGED
|
@@ -1,274 +1,236 @@
|
|
|
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 {
|
|
12
|
-
import {
|
|
13
|
-
import
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
} from '../
|
|
26
|
-
import type {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
engine.getEntitlement(key),
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
);
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
(
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
restore
|
|
236
|
-
|
|
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
|
-
}
|
|
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 { useExternalStore } from '@chipmobilesdk/rn-core/react';
|
|
12
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
13
|
+
import { usePaymentEngine } from './PaymentProvider';
|
|
14
|
+
import type {
|
|
15
|
+
EntitlementKey,
|
|
16
|
+
FinalizeResult,
|
|
17
|
+
PaymentStatus,
|
|
18
|
+
PendingPurchasesState,
|
|
19
|
+
PurchaseOptions,
|
|
20
|
+
PurchaseResult,
|
|
21
|
+
TransactionRef,
|
|
22
|
+
} from '../config/types';
|
|
23
|
+
import type { EntitlementMap, EntitlementState } from '../entitlements/aggregate';
|
|
24
|
+
import type { PaymentError } from '../diagnostics/errors';
|
|
25
|
+
import type { MissingProduct, ProductInfo } from '../engine/products';
|
|
26
|
+
import type { RestoreOutcome, SyncOutcome } from '../engine/engine';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One entitlement, re-rendering only when that one changes.
|
|
30
|
+
*
|
|
31
|
+
* The returned `status` is `unknown` until the first sync completes. That is the
|
|
32
|
+
* point: an app must be able to tell "not yet known" from "not entitled" so a
|
|
33
|
+
* paying user never sees the unpaid experience flash (FR-047).
|
|
34
|
+
*/
|
|
35
|
+
export function useEntitlement(key: EntitlementKey): EntitlementState {
|
|
36
|
+
const engine = usePaymentEngine();
|
|
37
|
+
// The engine keeps an unchanged entitlement's object across recomputes, so this
|
|
38
|
+
// selection only changes, and the component only re-renders, when it changes.
|
|
39
|
+
const select = useCallback(
|
|
40
|
+
(map: EntitlementMap) => map[key] ?? engine.getEntitlement(key),
|
|
41
|
+
[engine, key],
|
|
42
|
+
);
|
|
43
|
+
return useExternalStore(engine.subscribeEntitlements, engine.getAllEntitlements, select);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Every entitlement at once, for a screen that shows overall plan state. */
|
|
47
|
+
export function useEntitlements(): EntitlementMap {
|
|
48
|
+
const engine = usePaymentEngine();
|
|
49
|
+
return useExternalStore(engine.subscribeEntitlements, engine.getAllEntitlements);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function narrowPending(
|
|
53
|
+
state: PendingPurchasesState,
|
|
54
|
+
keys: readonly string[] | null,
|
|
55
|
+
): PendingPurchasesState {
|
|
56
|
+
return keys
|
|
57
|
+
? { ...state, purchases: state.purchases.filter(p => keys.includes(p.productKey)) }
|
|
58
|
+
: state;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Pending purchases, in this session or an earlier one (FR-101). Added in 0.2.0.
|
|
63
|
+
*
|
|
64
|
+
* Pass product keys to narrow it to the products one screen sells. Read
|
|
65
|
+
* `status` before the list: under `unknown`, an empty list is not "nothing
|
|
66
|
+
* pending", and a Buy button shown on it could charge a user twice.
|
|
67
|
+
*/
|
|
68
|
+
export function usePendingPurchases(productKeys?: readonly string[]): PendingPurchasesState {
|
|
69
|
+
const engine = usePaymentEngine();
|
|
70
|
+
// Joined so a caller passing a literal array does not resubscribe each render.
|
|
71
|
+
const keyToken = productKeys ? productKeys.join('|') : null;
|
|
72
|
+
const [state, setState] = useState<PendingPurchasesState>(() =>
|
|
73
|
+
narrowPending(engine.getPendingPurchases(), productKeys ?? null),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const keys = keyToken === null ? null : keyToken.split('|').filter(Boolean);
|
|
78
|
+
setState(narrowPending(engine.getPendingPurchases(), keys));
|
|
79
|
+
return engine.subscribePendingPurchases(next => setState(narrowPending(next, keys)));
|
|
80
|
+
}, [engine, keyToken]);
|
|
81
|
+
|
|
82
|
+
return state;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Package status, so an app can tell "cannot sell here" from "not paid" (FR-022). */
|
|
86
|
+
export function usePaymentStatus(): PaymentStatus {
|
|
87
|
+
const engine = usePaymentEngine();
|
|
88
|
+
return useExternalStore(engine.subscribeStatus, engine.getStatus);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface UsePurchaseResult {
|
|
92
|
+
purchase: (productKey: EntitlementKey, options?: PurchaseOptions) => Promise<PurchaseResult>;
|
|
93
|
+
consume: (ref: TransactionRef) => Promise<FinalizeResult>;
|
|
94
|
+
isPurchasing: boolean;
|
|
95
|
+
/** The last outcome, so a screen can render cancellation differently from failure. */
|
|
96
|
+
lastResult: PurchaseResult | undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Starting a purchase.
|
|
101
|
+
*
|
|
102
|
+
* `lastResult` is the full typed union rather than a boolean, because a screen has
|
|
103
|
+
* to treat `user_cancelled` differently from `store_error` — showing an error
|
|
104
|
+
* banner to someone who simply changed their mind is the most common paywall bug.
|
|
105
|
+
*/
|
|
106
|
+
export function usePurchase(): UsePurchaseResult {
|
|
107
|
+
const engine = usePaymentEngine();
|
|
108
|
+
const [isPurchasing, setIsPurchasing] = useState(false);
|
|
109
|
+
const [lastResult, setLastResult] = useState<PurchaseResult | undefined>();
|
|
110
|
+
|
|
111
|
+
const purchase = useCallback(
|
|
112
|
+
async (productKey: string, options?: PurchaseOptions): Promise<PurchaseResult> => {
|
|
113
|
+
setIsPurchasing(true);
|
|
114
|
+
try {
|
|
115
|
+
const result = await engine.purchase(productKey, options);
|
|
116
|
+
setLastResult(result);
|
|
117
|
+
return result;
|
|
118
|
+
} finally {
|
|
119
|
+
setIsPurchasing(false);
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
[engine],
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const consume = useCallback(
|
|
126
|
+
(ref: TransactionRef): Promise<FinalizeResult> => engine.consume(ref),
|
|
127
|
+
[engine],
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
return { purchase, consume, isPurchasing, lastResult };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface UseProductsResult {
|
|
134
|
+
products: ProductInfo[];
|
|
135
|
+
/** Reported so a paywall explains an absent row instead of rendering a blank. */
|
|
136
|
+
missing: MissingProduct[];
|
|
137
|
+
isLoading: boolean;
|
|
138
|
+
error: PaymentError | undefined;
|
|
139
|
+
refresh: () => Promise<void>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Store-supplied product information for a paywall.
|
|
144
|
+
*
|
|
145
|
+
* Every price here is already formatted by the store for the user's locale. An app
|
|
146
|
+
* must render `formattedPrice` and never build its own string from
|
|
147
|
+
* `priceAmountMicros` — that field exists for sorting and comparison only
|
|
148
|
+
* (FR-028).
|
|
149
|
+
*/
|
|
150
|
+
export function useProducts(productKeys: readonly string[]): UseProductsResult {
|
|
151
|
+
const engine = usePaymentEngine();
|
|
152
|
+
const [products, setProducts] = useState<ProductInfo[]>([]);
|
|
153
|
+
const [missing, setMissing] = useState<MissingProduct[]>([]);
|
|
154
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
155
|
+
const [error, setError] = useState<PaymentError | undefined>();
|
|
156
|
+
|
|
157
|
+
// Joined so the effect depends on the contents rather than the array identity:
|
|
158
|
+
// a caller passing a literal would otherwise re-query on every render.
|
|
159
|
+
const keyToken = productKeys.join('|');
|
|
160
|
+
|
|
161
|
+
const load = useCallback(
|
|
162
|
+
async (fresh: boolean): Promise<void> => {
|
|
163
|
+
const keys = keyToken === '' ? [] : keyToken.split('|');
|
|
164
|
+
if (keys.length === 0) {
|
|
165
|
+
setProducts([]);
|
|
166
|
+
setMissing([]);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
setIsLoading(true);
|
|
170
|
+
try {
|
|
171
|
+
const result = fresh ? await engine.refreshProducts(keys) : await engine.getProducts(keys);
|
|
172
|
+
if (result.ok) {
|
|
173
|
+
setProducts(result.products);
|
|
174
|
+
setMissing(result.missing);
|
|
175
|
+
setError(undefined);
|
|
176
|
+
} else {
|
|
177
|
+
setError(result.error);
|
|
178
|
+
}
|
|
179
|
+
} finally {
|
|
180
|
+
setIsLoading(false);
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
[engine, keyToken],
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
load(false).catch(() => undefined);
|
|
188
|
+
}, [load]);
|
|
189
|
+
|
|
190
|
+
const refresh = useCallback(() => load(true), [load]);
|
|
191
|
+
|
|
192
|
+
return { products, missing, isLoading, error, refresh };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface UseSyncResult {
|
|
196
|
+
sync: () => Promise<SyncOutcome>;
|
|
197
|
+
restore: () => Promise<RestoreOutcome>;
|
|
198
|
+
isSyncing: boolean;
|
|
199
|
+
lastSyncedAt: number | undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Sync and restore, for a "Restore purchases" control.
|
|
204
|
+
*
|
|
205
|
+
* `restore`'s outcome distinguishes "restored" from "nothing found", so the app
|
|
206
|
+
* can avoid telling a user their purchases came back when none existed (FR-061).
|
|
207
|
+
*/
|
|
208
|
+
export function useSync(): UseSyncResult {
|
|
209
|
+
const engine = usePaymentEngine();
|
|
210
|
+
const [isSyncing, setIsSyncing] = useState(false);
|
|
211
|
+
const [lastSyncedAt, setLastSyncedAt] = useState<number | undefined>(() =>
|
|
212
|
+
engine.getLastSyncedAt(),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const sync = useCallback(async (): Promise<SyncOutcome> => {
|
|
216
|
+
setIsSyncing(true);
|
|
217
|
+
try {
|
|
218
|
+
return await engine.sync('manual');
|
|
219
|
+
} finally {
|
|
220
|
+
setIsSyncing(false);
|
|
221
|
+
setLastSyncedAt(engine.getLastSyncedAt());
|
|
222
|
+
}
|
|
223
|
+
}, [engine]);
|
|
224
|
+
|
|
225
|
+
const restore = useCallback(async (): Promise<RestoreOutcome> => {
|
|
226
|
+
setIsSyncing(true);
|
|
227
|
+
try {
|
|
228
|
+
return await engine.restore();
|
|
229
|
+
} finally {
|
|
230
|
+
setIsSyncing(false);
|
|
231
|
+
setLastSyncedAt(engine.getLastSyncedAt());
|
|
232
|
+
}
|
|
233
|
+
}, [engine]);
|
|
234
|
+
|
|
235
|
+
return { sync, restore, isSyncing, lastSyncedAt };
|
|
236
|
+
}
|