@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/testing/fakeStore.ts
CHANGED
|
@@ -1,393 +1,360 @@
|
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
*
|
|
39
|
-
* is
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
/** Next `launchPurchase`
|
|
49
|
-
|
|
50
|
-
/** Next `launchPurchase`
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
/** The
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
): ProviderPurchase {
|
|
119
|
-
sequence += 1;
|
|
120
|
-
return {
|
|
121
|
-
storeProductId: overrides.storeProductId ?? 'store.product.default',
|
|
122
|
-
transactionId: overrides.transactionId ?? `txn_${sequence}`,
|
|
123
|
-
token: overrides.token ?? `token_${sequence}`,
|
|
124
|
-
state: overrides.state ?? 'purchased',
|
|
125
|
-
purchasedAt: overrides.purchasedAt ?? 1_700_000_000_000,
|
|
126
|
-
isAcknowledged: overrides.isAcknowledged ?? false,
|
|
127
|
-
expiresAt: overrides.expiresAt,
|
|
128
|
-
willAutoRenew: overrides.willAutoRenew,
|
|
129
|
-
verificationPayload: overrides.verificationPayload ?? 'fake-verification',
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export function createFakeStore(options: FakeStoreOptions = {}): FakeStore {
|
|
134
|
-
let connectOutcome: ProviderConnectOutcome = options.connectOutcome ?? {
|
|
135
|
-
ok: true,
|
|
136
|
-
};
|
|
137
|
-
const androidBinding = options.mode === 'android-16.7';
|
|
138
|
-
let capabilities: ProviderCapabilities = {
|
|
139
|
-
...DEFAULT_CAPABILITIES,
|
|
140
|
-
...(androidBinding ? { supportsPriceChangeConfirmation: false } : {}),
|
|
141
|
-
...options.capabilities,
|
|
142
|
-
};
|
|
143
|
-
let products = [...(options.products ?? [])];
|
|
144
|
-
let owned = [...(options.owned ?? [])];
|
|
145
|
-
|
|
146
|
-
let scripted:
|
|
147
|
-
| { kind: 'success' | 'pending'; overrides?: Partial<ProviderPurchase> }
|
|
148
|
-
| { kind: 'failure'; reason: ProviderFailureReason }
|
|
149
|
-
| undefined;
|
|
150
|
-
|
|
151
|
-
let finalizeFails = false;
|
|
152
|
-
let connected = false;
|
|
153
|
-
/** Why each pending purchase ended, for the ones `expirePending` or `cancelPending` ended. */
|
|
154
|
-
const endReasons = new Map<string, 'cancelled' | 'expired'>();
|
|
155
|
-
|
|
156
|
-
const acknowledgedTokens: ProviderToken[] = [];
|
|
157
|
-
const consumedTokens: ProviderToken[] = [];
|
|
158
|
-
const managementOpenedFor: (StoreProductId | undefined)[] = [];
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
>();
|
|
162
|
-
const purchaseListeners =
|
|
163
|
-
|
|
164
|
-
let connectCalls = 0;
|
|
165
|
-
let launchCalls = 0;
|
|
166
|
-
let queryOwnedCalls = 0;
|
|
167
|
-
let disconnectCalls = 0;
|
|
168
|
-
|
|
169
|
-
const notifyConnection = (next: boolean, reason?: string): void => {
|
|
170
|
-
connected = next;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
},
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
},
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
},
|
|
314
|
-
|
|
315
|
-
// ---
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
},
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
finalizeFails = failing;
|
|
362
|
-
},
|
|
363
|
-
|
|
364
|
-
// --- Inspection --------------------------------------------------------
|
|
365
|
-
acknowledged: () => [...acknowledgedTokens],
|
|
366
|
-
consumed: () => [...consumedTokens],
|
|
367
|
-
connectCount: () => connectCalls,
|
|
368
|
-
launchCount: () => launchCalls,
|
|
369
|
-
queryOwnedCount: () => queryOwnedCalls,
|
|
370
|
-
disconnectCount: () => disconnectCalls,
|
|
371
|
-
subscriptionManagementOpenedFor: () => [...managementOpenedFor],
|
|
372
|
-
|
|
373
|
-
reset() {
|
|
374
|
-
connectOutcome = { ok: true };
|
|
375
|
-
capabilities = { ...DEFAULT_CAPABILITIES, ...options.capabilities };
|
|
376
|
-
products = [];
|
|
377
|
-
owned = [];
|
|
378
|
-
scripted = undefined;
|
|
379
|
-
finalizeFails = false;
|
|
380
|
-
connected = false;
|
|
381
|
-
endReasons.clear();
|
|
382
|
-
acknowledgedTokens.length = 0;
|
|
383
|
-
consumedTokens.length = 0;
|
|
384
|
-
managementOpenedFor.length = 0;
|
|
385
|
-
connectionListeners.clear();
|
|
386
|
-
purchaseListeners.clear();
|
|
387
|
-
connectCalls = 0;
|
|
388
|
-
launchCalls = 0;
|
|
389
|
-
queryOwnedCalls = 0;
|
|
390
|
-
disconnectCalls = 0;
|
|
391
|
-
},
|
|
392
|
-
};
|
|
393
|
-
}
|
|
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 { createListenerSet } from '@chipmobilesdk/rn-core';
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
ProviderCapabilities,
|
|
18
|
+
ProviderConnectOutcome,
|
|
19
|
+
ProviderFailureReason,
|
|
20
|
+
ProviderPort,
|
|
21
|
+
ProviderPriceChangeOutcome,
|
|
22
|
+
ProviderProduct,
|
|
23
|
+
ProviderPurchase,
|
|
24
|
+
ProviderPurchaseOutcome,
|
|
25
|
+
ProviderPurchaseRequest,
|
|
26
|
+
ProviderToken,
|
|
27
|
+
StoreProductId,
|
|
28
|
+
} from '../adapter/port';
|
|
29
|
+
import type { ProductType, Unsubscribe } from '../config/types';
|
|
30
|
+
|
|
31
|
+
export interface FakeStoreOptions {
|
|
32
|
+
/** Fails `connect` until `setConnectOutcome` says otherwise. */
|
|
33
|
+
connectOutcome?: ProviderConnectOutcome;
|
|
34
|
+
capabilities?: Partial<ProviderCapabilities>;
|
|
35
|
+
products?: ProviderProduct[];
|
|
36
|
+
owned?: ProviderPurchase[];
|
|
37
|
+
/**
|
|
38
|
+
* `'android-16.7'` mirrors the Play binding the real adapter wraps: a launched
|
|
39
|
+
* purchase is also delivered through `observePurchases`, so the engine sees it
|
|
40
|
+
* twice, and capabilities match Android's. The default delivers it once, which
|
|
41
|
+
* is simpler but hides any double-settle on the device path.
|
|
42
|
+
*/
|
|
43
|
+
mode?: 'default' | 'android-16.7';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface FakeStore extends ProviderPort {
|
|
47
|
+
// --- Scripting the next purchase -------------------------------------------
|
|
48
|
+
/** Next `launchPurchase` succeeds and yields this purchase. */
|
|
49
|
+
scriptPurchaseSuccess(overrides?: Partial<ProviderPurchase>): void;
|
|
50
|
+
/** Next `launchPurchase` fails with this reason — cancellation included (FR-032). */
|
|
51
|
+
scriptPurchaseFailure(reason: ProviderFailureReason): void;
|
|
52
|
+
/** Next `launchPurchase` returns a purchase in the pending state (FR-037). */
|
|
53
|
+
scriptPurchasePending(overrides?: Partial<ProviderPurchase>): void;
|
|
54
|
+
|
|
55
|
+
// --- Store-side state ------------------------------------------------------
|
|
56
|
+
setOwned(purchases: ProviderPurchase[]): void;
|
|
57
|
+
/** Push a purchase through `observePurchases`, as a settling pending one does. */
|
|
58
|
+
pushPurchaseUpdate(purchase: ProviderPurchase): void;
|
|
59
|
+
/** Stop reporting a transaction as valid — a refund or revocation (FR-064). */
|
|
60
|
+
revoke(transactionId: string): void;
|
|
61
|
+
|
|
62
|
+
// --- Ending a pending purchase (0.2.0) -------------------------------------
|
|
63
|
+
// Each changes store-side state only, as the real store does: the engine sees
|
|
64
|
+
// it at its next sync, so it works the same across a simulated app kill. To
|
|
65
|
+
// deliver a settlement without a sync, `pushPurchaseUpdate` it as well.
|
|
66
|
+
/** The money arrives: the owned record turns `purchased` (FR-039). */
|
|
67
|
+
settlePending(transactionId: string): void;
|
|
68
|
+
/** The payment is declined or abandoned: the store stops reporting it (FR-040). */
|
|
69
|
+
cancelPending(transactionId: string): void;
|
|
70
|
+
/**
|
|
71
|
+
* The pending window runs out. Reported through `pendingEndReason`, which a
|
|
72
|
+
* store that knows implements — Play does not, so on device this is
|
|
73
|
+
* indistinguishable from `cancelPending` (FR-102).
|
|
74
|
+
*/
|
|
75
|
+
expirePending(transactionId: string): void;
|
|
76
|
+
setProducts(products: ProviderProduct[]): void;
|
|
77
|
+
|
|
78
|
+
// --- Connection ------------------------------------------------------------
|
|
79
|
+
setConnectOutcome(outcome: ProviderConnectOutcome): void;
|
|
80
|
+
/** Drop the connection, as the billing service does (FR-020). */
|
|
81
|
+
dropConnection(reason?: string): void;
|
|
82
|
+
restoreConnection(): void;
|
|
83
|
+
|
|
84
|
+
// --- Finalization ----------------------------------------------------------
|
|
85
|
+
/** Make `acknowledge`/`consume` fail, to exercise bounded retry (FR-045). */
|
|
86
|
+
setFinalizeFailure(failing: boolean): void;
|
|
87
|
+
|
|
88
|
+
// --- Inspection ------------------------------------------------------------
|
|
89
|
+
acknowledged(): ProviderToken[];
|
|
90
|
+
consumed(): ProviderToken[];
|
|
91
|
+
connectCount(): number;
|
|
92
|
+
launchCount(): number;
|
|
93
|
+
queryOwnedCount(): number;
|
|
94
|
+
disconnectCount(): number;
|
|
95
|
+
subscriptionManagementOpenedFor(): (StoreProductId | undefined)[];
|
|
96
|
+
reset(): void;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const DEFAULT_CAPABILITIES: ProviderCapabilities = {
|
|
100
|
+
supportsSubscriptions: true,
|
|
101
|
+
supportsPlanChange: true,
|
|
102
|
+
supportsPriceChangeConfirmation: true,
|
|
103
|
+
// Matches Android reality: grace and hold are not separable client-side.
|
|
104
|
+
reportsGraceAndHoldDistinctly: false,
|
|
105
|
+
supportsExternalTransactions: false,
|
|
106
|
+
// The whole point of the fake: it moves no money, so the environment guard
|
|
107
|
+
// lets a test complete a purchase while still blocking the real adapter
|
|
108
|
+
// outside production (FR-014).
|
|
109
|
+
createsRealTransactions: false,
|
|
110
|
+
// Its launch returns at once, so the engine's launch timeout applies and stays
|
|
111
|
+
// testable (FR-035).
|
|
112
|
+
purchaseOutcomeIncludesStoreTime: false,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
let sequence = 0;
|
|
116
|
+
|
|
117
|
+
/** A purchase with sensible defaults, so tests state only what they care about. */
|
|
118
|
+
export function makePurchase(overrides: Partial<ProviderPurchase> = {}): ProviderPurchase {
|
|
119
|
+
sequence += 1;
|
|
120
|
+
return {
|
|
121
|
+
storeProductId: overrides.storeProductId ?? 'store.product.default',
|
|
122
|
+
transactionId: overrides.transactionId ?? `txn_${sequence}`,
|
|
123
|
+
token: overrides.token ?? `token_${sequence}`,
|
|
124
|
+
state: overrides.state ?? 'purchased',
|
|
125
|
+
purchasedAt: overrides.purchasedAt ?? 1_700_000_000_000,
|
|
126
|
+
isAcknowledged: overrides.isAcknowledged ?? false,
|
|
127
|
+
expiresAt: overrides.expiresAt,
|
|
128
|
+
willAutoRenew: overrides.willAutoRenew,
|
|
129
|
+
verificationPayload: overrides.verificationPayload ?? 'fake-verification',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function createFakeStore(options: FakeStoreOptions = {}): FakeStore {
|
|
134
|
+
let connectOutcome: ProviderConnectOutcome = options.connectOutcome ?? {
|
|
135
|
+
ok: true,
|
|
136
|
+
};
|
|
137
|
+
const androidBinding = options.mode === 'android-16.7';
|
|
138
|
+
let capabilities: ProviderCapabilities = {
|
|
139
|
+
...DEFAULT_CAPABILITIES,
|
|
140
|
+
...(androidBinding ? { supportsPriceChangeConfirmation: false } : {}),
|
|
141
|
+
...options.capabilities,
|
|
142
|
+
};
|
|
143
|
+
let products = [...(options.products ?? [])];
|
|
144
|
+
let owned = [...(options.owned ?? [])];
|
|
145
|
+
|
|
146
|
+
let scripted:
|
|
147
|
+
| { kind: 'success' | 'pending'; overrides?: Partial<ProviderPurchase> }
|
|
148
|
+
| { kind: 'failure'; reason: ProviderFailureReason }
|
|
149
|
+
| undefined;
|
|
150
|
+
|
|
151
|
+
let finalizeFails = false;
|
|
152
|
+
let connected = false;
|
|
153
|
+
/** Why each pending purchase ended, for the ones `expirePending` or `cancelPending` ended. */
|
|
154
|
+
const endReasons = new Map<string, 'cancelled' | 'expired'>();
|
|
155
|
+
|
|
156
|
+
const acknowledgedTokens: ProviderToken[] = [];
|
|
157
|
+
const consumedTokens: ProviderToken[] = [];
|
|
158
|
+
const managementOpenedFor: (StoreProductId | undefined)[] = [];
|
|
159
|
+
// The same listener sets as the real adapter, so a throwing observer behaves
|
|
160
|
+
// identically here and on a device (PAY-17).
|
|
161
|
+
const connectionListeners = createListenerSet<[boolean, string | undefined]>();
|
|
162
|
+
const purchaseListeners = createListenerSet<[ProviderPurchase]>();
|
|
163
|
+
|
|
164
|
+
let connectCalls = 0;
|
|
165
|
+
let launchCalls = 0;
|
|
166
|
+
let queryOwnedCalls = 0;
|
|
167
|
+
let disconnectCalls = 0;
|
|
168
|
+
|
|
169
|
+
const notifyConnection = (next: boolean, reason?: string): void => {
|
|
170
|
+
connected = next;
|
|
171
|
+
connectionListeners.notify(next, reason);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
// --- ProviderPort ------------------------------------------------------
|
|
176
|
+
async connect(): Promise<ProviderConnectOutcome> {
|
|
177
|
+
connectCalls += 1;
|
|
178
|
+
if (connectOutcome.ok) {
|
|
179
|
+
notifyConnection(true);
|
|
180
|
+
}
|
|
181
|
+
return connectOutcome;
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
async disconnect(): Promise<void> {
|
|
185
|
+
disconnectCalls += 1;
|
|
186
|
+
if (connected) {
|
|
187
|
+
notifyConnection(false, 'disconnect');
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
observeConnection(listener): Unsubscribe {
|
|
192
|
+
return connectionListeners.add(listener);
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
async queryProducts(
|
|
196
|
+
storeProductIds: StoreProductId[],
|
|
197
|
+
productType: ProductType,
|
|
198
|
+
): Promise<ProviderProduct[]> {
|
|
199
|
+
return products.filter(
|
|
200
|
+
p => storeProductIds.includes(p.storeProductId) && 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(request: ProviderPurchaseRequest): Promise<ProviderPurchaseOutcome> {
|
|
212
|
+
launchCalls += 1;
|
|
213
|
+
const script = scripted;
|
|
214
|
+
scripted = undefined;
|
|
215
|
+
|
|
216
|
+
if (!script) {
|
|
217
|
+
return { ok: false, reason: 'store_error', detail: 'no script set' };
|
|
218
|
+
}
|
|
219
|
+
if (script.kind === 'failure') {
|
|
220
|
+
return { ok: false, reason: script.reason };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const purchase = makePurchase({
|
|
224
|
+
storeProductId: request.storeProductId,
|
|
225
|
+
state: script.kind === 'pending' ? 'pending' : 'purchased',
|
|
226
|
+
...script.overrides,
|
|
227
|
+
});
|
|
228
|
+
owned = [...owned.filter(p => p.transactionId !== purchase.transactionId), purchase];
|
|
229
|
+
if (androidBinding) {
|
|
230
|
+
// The binding's update listener fires for every observer, the session-long
|
|
231
|
+
// one included, before the launch settles.
|
|
232
|
+
purchaseListeners.notify(purchase);
|
|
233
|
+
}
|
|
234
|
+
return { ok: true, purchase };
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
observePurchases(listener): Unsubscribe {
|
|
238
|
+
return purchaseListeners.add(listener);
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
async acknowledge(token: ProviderToken): Promise<void> {
|
|
242
|
+
if (finalizeFails) {
|
|
243
|
+
throw new Error('fake acknowledge failure');
|
|
244
|
+
}
|
|
245
|
+
// Safe to call for an already-acknowledged transaction (FR-044).
|
|
246
|
+
if (!acknowledgedTokens.includes(token)) {
|
|
247
|
+
acknowledgedTokens.push(token);
|
|
248
|
+
}
|
|
249
|
+
owned = owned.map(p => (p.token === token ? { ...p, isAcknowledged: true } : p));
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
async consume(token: ProviderToken): Promise<void> {
|
|
253
|
+
if (finalizeFails) {
|
|
254
|
+
throw new Error('fake consume failure');
|
|
255
|
+
}
|
|
256
|
+
if (!consumedTokens.includes(token)) {
|
|
257
|
+
consumedTokens.push(token);
|
|
258
|
+
}
|
|
259
|
+
// A consumed purchase stops being owned, which is what lets the same
|
|
260
|
+
// product be bought again (FR-043).
|
|
261
|
+
owned = owned.filter(p => p.token !== token);
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
async openSubscriptionManagement(storeProductId?: StoreProductId): Promise<void> {
|
|
265
|
+
managementOpenedFor.push(storeProductId);
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
async confirmPriceChange(): Promise<ProviderPriceChangeOutcome> {
|
|
269
|
+
return { ok: true, confirmed: true };
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
capabilities: () => capabilities,
|
|
273
|
+
|
|
274
|
+
pendingEndReason: transactionId => endReasons.get(transactionId),
|
|
275
|
+
|
|
276
|
+
// --- Scripting ---------------------------------------------------------
|
|
277
|
+
scriptPurchaseSuccess(overrides) {
|
|
278
|
+
scripted = { kind: 'success', overrides };
|
|
279
|
+
},
|
|
280
|
+
scriptPurchaseFailure(reason) {
|
|
281
|
+
scripted = { kind: 'failure', reason };
|
|
282
|
+
},
|
|
283
|
+
scriptPurchasePending(overrides) {
|
|
284
|
+
scripted = { kind: 'pending', overrides };
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
// --- Store state -------------------------------------------------------
|
|
288
|
+
setOwned(purchases) {
|
|
289
|
+
owned = [...purchases];
|
|
290
|
+
},
|
|
291
|
+
pushPurchaseUpdate(purchase) {
|
|
292
|
+
owned = [...owned.filter(p => p.transactionId !== purchase.transactionId), purchase];
|
|
293
|
+
purchaseListeners.notify(purchase);
|
|
294
|
+
},
|
|
295
|
+
revoke(transactionId) {
|
|
296
|
+
owned = owned.filter(p => p.transactionId !== transactionId);
|
|
297
|
+
},
|
|
298
|
+
settlePending(transactionId) {
|
|
299
|
+
owned = owned.map(p =>
|
|
300
|
+
p.transactionId === transactionId ? { ...p, state: 'purchased' } : p,
|
|
301
|
+
);
|
|
302
|
+
},
|
|
303
|
+
cancelPending(transactionId) {
|
|
304
|
+
owned = owned.filter(p => p.transactionId !== transactionId);
|
|
305
|
+
endReasons.set(transactionId, 'cancelled');
|
|
306
|
+
},
|
|
307
|
+
expirePending(transactionId) {
|
|
308
|
+
owned = owned.filter(p => p.transactionId !== transactionId);
|
|
309
|
+
endReasons.set(transactionId, 'expired');
|
|
310
|
+
},
|
|
311
|
+
setProducts(next) {
|
|
312
|
+
products = [...next];
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
// --- Connection --------------------------------------------------------
|
|
316
|
+
setConnectOutcome(outcome) {
|
|
317
|
+
connectOutcome = outcome;
|
|
318
|
+
},
|
|
319
|
+
dropConnection(reason = 'service_disconnected') {
|
|
320
|
+
notifyConnection(false, reason);
|
|
321
|
+
},
|
|
322
|
+
restoreConnection() {
|
|
323
|
+
notifyConnection(true);
|
|
324
|
+
},
|
|
325
|
+
|
|
326
|
+
// --- Finalization ------------------------------------------------------
|
|
327
|
+
setFinalizeFailure(failing) {
|
|
328
|
+
finalizeFails = failing;
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
// --- Inspection --------------------------------------------------------
|
|
332
|
+
acknowledged: () => [...acknowledgedTokens],
|
|
333
|
+
consumed: () => [...consumedTokens],
|
|
334
|
+
connectCount: () => connectCalls,
|
|
335
|
+
launchCount: () => launchCalls,
|
|
336
|
+
queryOwnedCount: () => queryOwnedCalls,
|
|
337
|
+
disconnectCount: () => disconnectCalls,
|
|
338
|
+
subscriptionManagementOpenedFor: () => [...managementOpenedFor],
|
|
339
|
+
|
|
340
|
+
reset() {
|
|
341
|
+
connectOutcome = { ok: true };
|
|
342
|
+
capabilities = { ...DEFAULT_CAPABILITIES, ...options.capabilities };
|
|
343
|
+
products = [];
|
|
344
|
+
owned = [];
|
|
345
|
+
scripted = undefined;
|
|
346
|
+
finalizeFails = false;
|
|
347
|
+
connected = false;
|
|
348
|
+
endReasons.clear();
|
|
349
|
+
acknowledgedTokens.length = 0;
|
|
350
|
+
consumedTokens.length = 0;
|
|
351
|
+
managementOpenedFor.length = 0;
|
|
352
|
+
connectionListeners.clear();
|
|
353
|
+
purchaseListeners.clear();
|
|
354
|
+
connectCalls = 0;
|
|
355
|
+
launchCalls = 0;
|
|
356
|
+
queryOwnedCalls = 0;
|
|
357
|
+
disconnectCalls = 0;
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
}
|