@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.
- package/CHANGELOG.md +202 -0
- package/README.md +70 -16
- package/package.json +5 -2
- package/skills/sdk-rn-payment/SKILL.md +8 -6
- package/src/adapter/iap-adapter.ts +44 -251
- package/src/adapter/iapShapes.ts +232 -0
- package/src/config/types.ts +8 -0
- package/src/config/validate.ts +19 -4
- package/src/core/mutex.ts +9 -110
- package/src/core/retry.ts +9 -104
- package/src/core/timers.ts +40 -18
- package/src/diagnostics/errors.ts +20 -13
- package/src/diagnostics/events.ts +51 -32
- package/src/diagnostics/messageKeys.ts +15 -9
- package/src/engine/connection.ts +194 -0
- package/src/engine/engine.ts +142 -733
- package/src/engine/foreground.ts +29 -0
- package/src/engine/internals.ts +40 -0
- package/src/engine/products.ts +39 -17
- package/src/engine/purchase.ts +22 -130
- package/src/engine/purchaseFailure.ts +48 -0
- package/src/engine/receiptValidation.ts +101 -0
- package/src/engine/state.ts +253 -0
- package/src/engine/sync.ts +161 -0
- package/src/entitlements/aggregate.ts +37 -5
- package/src/entitlements/mapping.ts +0 -22
- package/src/index.ts +3 -0
- package/src/persistence/mmkv-store.ts +45 -35
- package/src/persistence/snapshot.ts +102 -27
- package/src/react/PaymentProvider.tsx +85 -23
- package/src/react/hooks.ts +23 -34
- package/src/testing/conformance.ts +169 -0
- package/src/testing/fakeStore.ts +11 -29
- package/src/testing/index.ts +9 -0
|
@@ -9,12 +9,16 @@
|
|
|
9
9
|
* identity, raw receipts, verification payloads, and backend tokens.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import type {
|
|
12
|
+
import type { ProductKey, ProductType } from '../config/types';
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* 2 since 0.6.0: the never-written `entitlements` map and `finalized` state are
|
|
16
|
+
* gone (PAY-12). Entitlements are always derived from purchases, never stored.
|
|
17
|
+
*/
|
|
18
|
+
export const SNAPSHOT_SCHEMA_VERSION = 2;
|
|
15
19
|
|
|
16
20
|
/** Where a transaction stands with the store. */
|
|
17
|
-
export type PersistedPurchaseState = 'pending' | 'purchased' | '
|
|
21
|
+
export type PersistedPurchaseState = 'pending' | 'purchased' | 'consumed' | 'revoked';
|
|
18
22
|
|
|
19
23
|
/**
|
|
20
24
|
* Where a transaction stands in the finalization queue.
|
|
@@ -44,18 +48,10 @@ export interface PersistedPurchase {
|
|
|
44
48
|
willAutoRenew?: boolean;
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
export interface PersistedEntitlement {
|
|
48
|
-
sources: ProductKey[];
|
|
49
|
-
expiresAt?: number;
|
|
50
|
-
willAutoRenew?: boolean;
|
|
51
|
-
isCancelledButValid?: boolean;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
51
|
export interface PersistedSnapshot {
|
|
55
52
|
schemaVersion: number;
|
|
56
53
|
/** Opaque partition key. Stored only to scope this record (FR-057a). */
|
|
57
54
|
scopeToken?: string;
|
|
58
|
-
entitlements: Record<EntitlementKey, PersistedEntitlement>;
|
|
59
55
|
purchases: PersistedPurchase[];
|
|
60
56
|
lastSyncedAt?: number;
|
|
61
57
|
}
|
|
@@ -64,7 +60,6 @@ export function emptySnapshot(scopeToken?: string): PersistedSnapshot {
|
|
|
64
60
|
return {
|
|
65
61
|
schemaVersion: SNAPSHOT_SCHEMA_VERSION,
|
|
66
62
|
scopeToken,
|
|
67
|
-
entitlements: {},
|
|
68
63
|
purchases: [],
|
|
69
64
|
lastSyncedAt: undefined,
|
|
70
65
|
};
|
|
@@ -83,14 +78,30 @@ export interface SnapshotMigration {
|
|
|
83
78
|
migrate(input: Record<string, unknown>): Record<string, unknown>;
|
|
84
79
|
}
|
|
85
80
|
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
81
|
+
/** Registered migrations, one hop each. */
|
|
82
|
+
export const SNAPSHOT_MIGRATIONS: readonly SnapshotMigration[] = [
|
|
83
|
+
{
|
|
84
|
+
// v1 stored an `entitlements` map nothing ever wrote to, and a `finalized`
|
|
85
|
+
// purchase state nothing ever set (PAY-12). Both go; a `finalized` record,
|
|
86
|
+
// should one exist, is a purchased one whose finalize progress is kept.
|
|
87
|
+
from: 1,
|
|
88
|
+
to: 2,
|
|
89
|
+
migrate(input) {
|
|
90
|
+
const rest = { ...input };
|
|
91
|
+
delete rest.entitlements;
|
|
92
|
+
const purchases = Array.isArray(rest.purchases)
|
|
93
|
+
? rest.purchases.map(record =>
|
|
94
|
+
typeof record === 'object' &&
|
|
95
|
+
record !== null &&
|
|
96
|
+
(record as { state?: unknown }).state === 'finalized'
|
|
97
|
+
? { ...(record as object), state: 'purchased' }
|
|
98
|
+
: record,
|
|
99
|
+
)
|
|
100
|
+
: rest.purchases;
|
|
101
|
+
return { ...rest, schemaVersion: 2, purchases };
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
];
|
|
94
105
|
|
|
95
106
|
export type MigrationOutcome =
|
|
96
107
|
| { ok: true; snapshot: PersistedSnapshot; migrated: boolean }
|
|
@@ -146,12 +157,7 @@ export function migrateSnapshot(raw: unknown): MigrationOutcome {
|
|
|
146
157
|
* truncated file.
|
|
147
158
|
*/
|
|
148
159
|
function coerceSnapshot(input: Record<string, unknown>): PersistedSnapshot | null {
|
|
149
|
-
const entitlements = input.entitlements;
|
|
150
160
|
const purchases = input.purchases;
|
|
151
|
-
|
|
152
|
-
if (typeof entitlements !== 'object' || entitlements === null) {
|
|
153
|
-
return null;
|
|
154
|
-
}
|
|
155
161
|
if (!Array.isArray(purchases)) {
|
|
156
162
|
return null;
|
|
157
163
|
}
|
|
@@ -162,12 +168,81 @@ function coerceSnapshot(input: Record<string, unknown>): PersistedSnapshot | nul
|
|
|
162
168
|
return {
|
|
163
169
|
schemaVersion: SNAPSHOT_SCHEMA_VERSION,
|
|
164
170
|
scopeToken,
|
|
165
|
-
|
|
166
|
-
|
|
171
|
+
// Record by record: one damaged entry is dropped, not trusted and not
|
|
172
|
+
// allowed to discard the rest (PAY-13). A dropped record comes back with the
|
|
173
|
+
// next sync, if the store still reports it.
|
|
174
|
+
purchases: purchases.flatMap(record => {
|
|
175
|
+
const valid = coercePurchase(record);
|
|
176
|
+
return valid ? [valid] : [];
|
|
177
|
+
}),
|
|
167
178
|
lastSyncedAt,
|
|
168
179
|
};
|
|
169
180
|
}
|
|
170
181
|
|
|
182
|
+
const PURCHASE_STATES: ReadonlySet<string> = new Set([
|
|
183
|
+
'pending',
|
|
184
|
+
'purchased',
|
|
185
|
+
'consumed',
|
|
186
|
+
'revoked',
|
|
187
|
+
]);
|
|
188
|
+
const FINALIZE_STATES: ReadonlySet<string> = new Set([
|
|
189
|
+
'not_required',
|
|
190
|
+
'queued',
|
|
191
|
+
'in_flight',
|
|
192
|
+
'done',
|
|
193
|
+
'failed',
|
|
194
|
+
]);
|
|
195
|
+
const VALIDATION_STATES: ReadonlySet<string> = new Set([
|
|
196
|
+
'not_required',
|
|
197
|
+
'approved',
|
|
198
|
+
'rejected',
|
|
199
|
+
'queued_for_revalidation',
|
|
200
|
+
]);
|
|
201
|
+
const PRODUCT_TYPES: ReadonlySet<string> = new Set([
|
|
202
|
+
'one_time_permanent',
|
|
203
|
+
'one_time_consumable',
|
|
204
|
+
'subscription',
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
/** A persisted purchase with every field checked, or null when any is wrong. */
|
|
208
|
+
function coercePurchase(input: unknown): PersistedPurchase | null {
|
|
209
|
+
if (typeof input !== 'object' || input === null) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
const record = input as Record<string, unknown>;
|
|
213
|
+
const text = (value: unknown): value is string => typeof value === 'string' && value !== '';
|
|
214
|
+
const count = (value: unknown): value is number =>
|
|
215
|
+
typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
|
216
|
+
if (
|
|
217
|
+
!text(record.transactionId) ||
|
|
218
|
+
!text(record.productKey) ||
|
|
219
|
+
!text(record.token) ||
|
|
220
|
+
!PRODUCT_TYPES.has(record.productType as string) ||
|
|
221
|
+
!PURCHASE_STATES.has(record.state as string) ||
|
|
222
|
+
!FINALIZE_STATES.has(record.finalizeState as string) ||
|
|
223
|
+
!VALIDATION_STATES.has(record.validationState as string) ||
|
|
224
|
+
!count(record.purchasedAt) ||
|
|
225
|
+
!count(record.attempts) ||
|
|
226
|
+
(record.expiresAt !== undefined && !count(record.expiresAt)) ||
|
|
227
|
+
(record.willAutoRenew !== undefined && typeof record.willAutoRenew !== 'boolean')
|
|
228
|
+
) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
transactionId: record.transactionId,
|
|
233
|
+
productKey: record.productKey,
|
|
234
|
+
productType: record.productType as PersistedPurchase['productType'],
|
|
235
|
+
token: record.token,
|
|
236
|
+
state: record.state as PersistedPurchaseState,
|
|
237
|
+
purchasedAt: record.purchasedAt,
|
|
238
|
+
finalizeState: record.finalizeState as PersistedFinalizeState,
|
|
239
|
+
attempts: record.attempts,
|
|
240
|
+
validationState: record.validationState as PersistedValidationState,
|
|
241
|
+
expiresAt: record.expiresAt as number | undefined,
|
|
242
|
+
willAutoRenew: record.willAutoRenew as boolean | undefined,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
171
246
|
/**
|
|
172
247
|
* Decides a purchase's finalization state from the store's view plus what we
|
|
173
248
|
* already knew.
|
|
@@ -10,50 +10,112 @@ import React, {
|
|
|
10
10
|
createContext,
|
|
11
11
|
useContext,
|
|
12
12
|
useEffect,
|
|
13
|
-
useMemo,
|
|
14
13
|
useRef,
|
|
14
|
+
useState,
|
|
15
15
|
type PropsWithChildren,
|
|
16
16
|
} from 'react';
|
|
17
17
|
import { createEngine, type EngineDeps, type PaymentEngine } from '../engine/engine';
|
|
18
|
+
import { syncOnForeground as watchForeground } from '../engine/foreground';
|
|
18
19
|
import type { PaymentConfig } from '../config/types';
|
|
19
20
|
|
|
20
21
|
const PaymentContext = createContext<PaymentEngine | null>(null);
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
interface OwnedEngineProps {
|
|
23
24
|
config: PaymentConfig;
|
|
24
25
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* Required until the real adapter ships; also how a test or the demo app
|
|
28
|
-
* supplies the fake store. Keeping it explicit rather than defaulting to a
|
|
29
|
-
* lazily-required binding means a missing native module is a clear
|
|
30
|
-
* configuration error instead of a mysterious runtime failure.
|
|
26
|
+
* The store provider and storage. Usually `{ provider: createStoreProvider() }`;
|
|
27
|
+
* a test or the demo app supplies the fake store here.
|
|
31
28
|
*/
|
|
32
29
|
deps: EngineDeps;
|
|
30
|
+
/**
|
|
31
|
+
* When this changes, the provider builds a new engine. Defaults to
|
|
32
|
+
* `config.scopeToken`, so switching accounts gets a fresh one. `config` and
|
|
33
|
+
* `deps` themselves are read when an engine is built and may be inline objects:
|
|
34
|
+
* a new object on every render no longer reconnects to the store (PAY-07).
|
|
35
|
+
*/
|
|
36
|
+
engineKey?: string;
|
|
37
|
+
engine?: undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface ExternalEngineProps {
|
|
41
|
+
/**
|
|
42
|
+
* An engine the app created and owns. The provider shares it with the tree and
|
|
43
|
+
* neither initializes nor cleans it up.
|
|
44
|
+
*/
|
|
45
|
+
engine: PaymentEngine;
|
|
46
|
+
config?: undefined;
|
|
47
|
+
deps?: undefined;
|
|
48
|
+
engineKey?: undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type PaymentProviderProps = (OwnedEngineProps | ExternalEngineProps) & {
|
|
33
52
|
/** Skip automatic initialization when the app wants to control the moment. */
|
|
34
53
|
autoInitialize?: boolean;
|
|
35
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Call `handleForeground()` each time the app returns from the background, through
|
|
56
|
+
* rn-core's shared lifecycle port. Off by default: what counts as a foreground
|
|
57
|
+
* return is the app's decision (FR-059).
|
|
58
|
+
*/
|
|
59
|
+
syncOnForeground?: boolean;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export function PaymentProvider(
|
|
63
|
+
props: PropsWithChildren<PaymentProviderProps>,
|
|
64
|
+
): React.ReactElement {
|
|
65
|
+
const { autoInitialize = true, syncOnForeground = false, children } = props;
|
|
66
|
+
const external = props.engine;
|
|
67
|
+
const key = props.engine ? undefined : (props.engineKey ?? props.config.scopeToken);
|
|
68
|
+
|
|
69
|
+
// The latest config and deps, read only when an engine is built.
|
|
70
|
+
const latest = useRef(props);
|
|
71
|
+
latest.current = props;
|
|
72
|
+
const build = (): PaymentEngine => {
|
|
73
|
+
const current = latest.current;
|
|
74
|
+
if (current.engine) {
|
|
75
|
+
return current.engine;
|
|
76
|
+
}
|
|
77
|
+
return createEngine(current.config, current.deps);
|
|
78
|
+
};
|
|
36
79
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
// to the billing service repeatedly, which FR-018 exists to prevent.
|
|
45
|
-
const engine = useMemo(() => createEngine(config, deps), [config, deps]);
|
|
46
|
-
const engineRef = useRef<PaymentEngine>(engine);
|
|
47
|
-
engineRef.current = engine;
|
|
80
|
+
// Built once, not once per render: validating the catalog and opening storage
|
|
81
|
+
// are not render work, and a new engine per render reconnected to the billing
|
|
82
|
+
// service every time the app's root re-rendered (FR-018, PAY-07).
|
|
83
|
+
const [owned, setOwned] = useState<PaymentEngine | null>(() => (external ? null : build()));
|
|
84
|
+
const builtFor = useRef(key);
|
|
85
|
+
const cleanedUp = useRef(new WeakSet<PaymentEngine>());
|
|
86
|
+
const engine = external ?? owned ?? build();
|
|
48
87
|
|
|
49
88
|
useEffect(() => {
|
|
89
|
+
if (external) {
|
|
90
|
+
// The app's engine, so the app's lifecycle.
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
const cleaned = cleanedUp.current;
|
|
94
|
+
// An engine cleaned up by an earlier run of this effect — StrictMode's
|
|
95
|
+
// mount-unmount-mount in development, or a changed `engineKey` — is never
|
|
96
|
+
// reused: it would answer every call with "cleaned up". A fresh one replaces
|
|
97
|
+
// it and this effect runs again for that one.
|
|
98
|
+
if (!owned || cleaned.has(owned) || builtFor.current !== key) {
|
|
99
|
+
builtFor.current = key;
|
|
100
|
+
setOwned(build());
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
50
103
|
if (autoInitialize) {
|
|
51
|
-
|
|
104
|
+
owned.initialize().catch(() => undefined);
|
|
52
105
|
}
|
|
53
106
|
// Cleanup on unmount is the whole reason the provider owns the engine:
|
|
54
107
|
// after it, no event fires and no timer survives (FR-082).
|
|
55
|
-
return () =>
|
|
56
|
-
|
|
108
|
+
return () => {
|
|
109
|
+
cleaned.add(owned);
|
|
110
|
+
owned.cleanup();
|
|
111
|
+
};
|
|
112
|
+
// `build` reads the latest props through a ref, so it is not a dependency.
|
|
113
|
+
}, [external, owned, key, autoInitialize]);
|
|
114
|
+
|
|
115
|
+
useEffect(
|
|
116
|
+
() => (syncOnForeground ? watchForeground(engine) : undefined),
|
|
117
|
+
[engine, syncOnForeground],
|
|
118
|
+
);
|
|
57
119
|
|
|
58
120
|
return <PaymentContext.Provider value={engine}>{children}</PaymentContext.Provider>;
|
|
59
121
|
}
|
package/src/react/hooks.ts
CHANGED
|
@@ -8,13 +8,15 @@
|
|
|
8
8
|
* returns a loading placeholder for an entitlement it already knows (PERF-001).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { useExternalStore } from '@chipmobilesdk/rn-core/react';
|
|
12
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
12
13
|
import { usePaymentEngine } from './PaymentProvider';
|
|
13
14
|
import type {
|
|
14
15
|
EntitlementKey,
|
|
15
16
|
FinalizeResult,
|
|
16
17
|
PaymentStatus,
|
|
17
18
|
PendingPurchasesState,
|
|
19
|
+
ProductKey,
|
|
18
20
|
PurchaseOptions,
|
|
19
21
|
PurchaseResult,
|
|
20
22
|
TransactionRef,
|
|
@@ -33,34 +35,19 @@ import type { RestoreOutcome, SyncOutcome } from '../engine/engine';
|
|
|
33
35
|
*/
|
|
34
36
|
export function useEntitlement(key: EntitlementKey): EntitlementState {
|
|
35
37
|
const engine = usePaymentEngine();
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const next = map[key];
|
|
44
|
-
if (next) {
|
|
45
|
-
setState(next);
|
|
46
|
-
}
|
|
47
|
-
});
|
|
48
|
-
}, [engine, key]);
|
|
49
|
-
|
|
50
|
-
return state;
|
|
38
|
+
// The engine keeps an unchanged entitlement's object across recomputes, so this
|
|
39
|
+
// selection only changes, and the component only re-renders, when it changes.
|
|
40
|
+
const select = useCallback(
|
|
41
|
+
(map: EntitlementMap) => map[key] ?? engine.getEntitlement(key),
|
|
42
|
+
[engine, key],
|
|
43
|
+
);
|
|
44
|
+
return useExternalStore(engine.subscribeEntitlements, engine.getAllEntitlements, select);
|
|
51
45
|
}
|
|
52
46
|
|
|
53
47
|
/** Every entitlement at once, for a screen that shows overall plan state. */
|
|
54
48
|
export function useEntitlements(): EntitlementMap {
|
|
55
49
|
const engine = usePaymentEngine();
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
useEffect(() => {
|
|
59
|
-
setMap(engine.getAllEntitlements());
|
|
60
|
-
return engine.subscribeEntitlements(setMap);
|
|
61
|
-
}, [engine]);
|
|
62
|
-
|
|
63
|
-
return map;
|
|
50
|
+
return useExternalStore(engine.subscribeEntitlements, engine.getAllEntitlements);
|
|
64
51
|
}
|
|
65
52
|
|
|
66
53
|
function narrowPending(
|
|
@@ -99,18 +86,11 @@ export function usePendingPurchases(productKeys?: readonly string[]): PendingPur
|
|
|
99
86
|
/** Package status, so an app can tell "cannot sell here" from "not paid" (FR-022). */
|
|
100
87
|
export function usePaymentStatus(): PaymentStatus {
|
|
101
88
|
const engine = usePaymentEngine();
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
useEffect(() => {
|
|
105
|
-
setStatus(engine.getStatus());
|
|
106
|
-
return engine.subscribeStatus(setStatus);
|
|
107
|
-
}, [engine]);
|
|
108
|
-
|
|
109
|
-
return status;
|
|
89
|
+
return useExternalStore(engine.subscribeStatus, engine.getStatus);
|
|
110
90
|
}
|
|
111
91
|
|
|
112
92
|
export interface UsePurchaseResult {
|
|
113
|
-
purchase: (productKey:
|
|
93
|
+
purchase: (productKey: ProductKey, options?: PurchaseOptions) => Promise<PurchaseResult>;
|
|
114
94
|
consume: (ref: TransactionRef) => Promise<FinalizeResult>;
|
|
115
95
|
isPurchasing: boolean;
|
|
116
96
|
/** The last outcome, so a screen can render cancellation differently from failure. */
|
|
@@ -178,9 +158,13 @@ export function useProducts(productKeys: readonly string[]): UseProductsResult {
|
|
|
178
158
|
// Joined so the effect depends on the contents rather than the array identity:
|
|
179
159
|
// a caller passing a literal would otherwise re-query on every render.
|
|
180
160
|
const keyToken = productKeys.join('|');
|
|
161
|
+
// Each load's number. Only the latest may write: when the keys change while a
|
|
162
|
+
// query is out, the older answer must not overwrite the newer one (PAY-20).
|
|
163
|
+
const latestRequest = useRef(0);
|
|
181
164
|
|
|
182
165
|
const load = useCallback(
|
|
183
166
|
async (fresh: boolean): Promise<void> => {
|
|
167
|
+
const request = ++latestRequest.current;
|
|
184
168
|
const keys = keyToken === '' ? [] : keyToken.split('|');
|
|
185
169
|
if (keys.length === 0) {
|
|
186
170
|
setProducts([]);
|
|
@@ -190,6 +174,9 @@ export function useProducts(productKeys: readonly string[]): UseProductsResult {
|
|
|
190
174
|
setIsLoading(true);
|
|
191
175
|
try {
|
|
192
176
|
const result = fresh ? await engine.refreshProducts(keys) : await engine.getProducts(keys);
|
|
177
|
+
if (request !== latestRequest.current) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
193
180
|
if (result.ok) {
|
|
194
181
|
setProducts(result.products);
|
|
195
182
|
setMissing(result.missing);
|
|
@@ -198,7 +185,9 @@ export function useProducts(productKeys: readonly string[]): UseProductsResult {
|
|
|
198
185
|
setError(result.error);
|
|
199
186
|
}
|
|
200
187
|
} finally {
|
|
201
|
-
|
|
188
|
+
if (request === latestRequest.current) {
|
|
189
|
+
setIsLoading(false);
|
|
190
|
+
}
|
|
202
191
|
}
|
|
203
192
|
},
|
|
204
193
|
[engine, keyToken],
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What any `ProviderPort` must do (IOS-12, PAY-13).
|
|
3
|
+
*
|
|
4
|
+
* The same checks run against the fake store and against the Play adapter over a
|
|
5
|
+
* simulated binding (or the real one, from a demo screen on a device), so a test
|
|
6
|
+
* that drives the fake proves something about the device path. Each check builds
|
|
7
|
+
* its own port over the same small store, through a harness.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
ensure,
|
|
11
|
+
runChecks,
|
|
12
|
+
type Check,
|
|
13
|
+
type ConformanceReport,
|
|
14
|
+
} from '@chipmobilesdk/rn-core/testing';
|
|
15
|
+
|
|
16
|
+
import type { ProviderPort } from '../adapter/port';
|
|
17
|
+
import type { ProductType } from '../config/types';
|
|
18
|
+
|
|
19
|
+
export interface StoreProductSpec {
|
|
20
|
+
readonly storeProductId: string;
|
|
21
|
+
readonly productType: ProductType;
|
|
22
|
+
/** As the store formats it; the port must pass it through untouched. */
|
|
23
|
+
readonly formattedPrice: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface OwnedPurchaseSpec {
|
|
27
|
+
readonly storeProductId: string;
|
|
28
|
+
readonly token: string;
|
|
29
|
+
readonly state: 'purchased' | 'pending';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface StoreScenario {
|
|
33
|
+
readonly products: readonly StoreProductSpec[];
|
|
34
|
+
readonly owned: readonly OwnedPurchaseSpec[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ProviderSubject {
|
|
38
|
+
readonly port: ProviderPort;
|
|
39
|
+
/** How the next purchase flow the user starts ends. */
|
|
40
|
+
nextPurchaseEnds(outcome: 'bought' | 'cancelled'): void;
|
|
41
|
+
/** The billing service goes away underneath the app. */
|
|
42
|
+
dropService(): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ProviderHarness {
|
|
46
|
+
create(scenario: StoreScenario): ProviderSubject;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The store every check runs against. */
|
|
50
|
+
export const CONFORMANCE_STORE: StoreScenario = {
|
|
51
|
+
products: [
|
|
52
|
+
{
|
|
53
|
+
storeProductId: 'conformance.remove_ads',
|
|
54
|
+
productType: 'one_time_permanent',
|
|
55
|
+
formattedPrice: '€1.99',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
storeProductId: 'conformance.coins',
|
|
59
|
+
productType: 'one_time_consumable',
|
|
60
|
+
formattedPrice: '€0.99',
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
owned: [
|
|
64
|
+
{ storeProductId: 'conformance.remove_ads', token: 'token-owned', state: 'purchased' },
|
|
65
|
+
{ storeProductId: 'conformance.coins', token: 'token-pending', state: 'pending' },
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const providerPortChecks: readonly Check<ProviderHarness>[] = [
|
|
70
|
+
{
|
|
71
|
+
name: 'connects, and disconnects safely before a connect and twice',
|
|
72
|
+
async run(harness) {
|
|
73
|
+
const { port } = harness.create(CONFORMANCE_STORE);
|
|
74
|
+
await port.disconnect();
|
|
75
|
+
const outcome = await port.connect();
|
|
76
|
+
ensure(outcome.ok, `connect failed: ${JSON.stringify(outcome)}`);
|
|
77
|
+
await port.disconnect();
|
|
78
|
+
await port.disconnect();
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'reports a dropped service to observers, and nothing after unsubscribing',
|
|
83
|
+
async run(harness) {
|
|
84
|
+
const subject = harness.create(CONFORMANCE_STORE);
|
|
85
|
+
const seen: boolean[] = [];
|
|
86
|
+
const stop = subject.port.observeConnection(connected => seen.push(connected));
|
|
87
|
+
await subject.port.connect();
|
|
88
|
+
ensure(seen.at(-1) === true, `after connect observers saw ${JSON.stringify(seen)}`);
|
|
89
|
+
subject.dropService();
|
|
90
|
+
ensure(seen.at(-1) === false, `after the drop observers saw ${JSON.stringify(seen)}`);
|
|
91
|
+
stop();
|
|
92
|
+
const before = seen.length;
|
|
93
|
+
await subject.port.connect();
|
|
94
|
+
ensure(seen.length === before, 'notified after unsubscribing');
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: 'returns the requested products with the store-formatted price',
|
|
99
|
+
async run(harness) {
|
|
100
|
+
const { port } = harness.create(CONFORMANCE_STORE);
|
|
101
|
+
await port.connect();
|
|
102
|
+
const products = await port.queryProducts(['conformance.remove_ads'], 'one_time_permanent');
|
|
103
|
+
ensure(products.length === 1, `expected one product, got ${products.length}`);
|
|
104
|
+
const [product] = products;
|
|
105
|
+
ensure(product?.storeProductId === 'conformance.remove_ads', 'wrong product returned');
|
|
106
|
+
ensure(product.productType === 'one_time_permanent', `product type ${product.productType}`);
|
|
107
|
+
ensure(product.formattedPrice === '€1.99', `price ${product.formattedPrice}`);
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: 'flags a pending purchase rather than filtering it out',
|
|
112
|
+
async run(harness) {
|
|
113
|
+
const { port } = harness.create(CONFORMANCE_STORE);
|
|
114
|
+
await port.connect();
|
|
115
|
+
const owned = await port.queryOwned();
|
|
116
|
+
const pending = owned.find(purchase => purchase.token === 'token-pending');
|
|
117
|
+
const bought = owned.find(purchase => purchase.token === 'token-owned');
|
|
118
|
+
ensure(pending?.state === 'pending', `pending purchase reported as ${pending?.state}`);
|
|
119
|
+
ensure(bought?.state === 'purchased', `bought purchase reported as ${bought?.state}`);
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: 'reports a bought purchase for the product that was requested',
|
|
124
|
+
async run(harness) {
|
|
125
|
+
const subject = harness.create(CONFORMANCE_STORE);
|
|
126
|
+
await subject.port.connect();
|
|
127
|
+
subject.nextPurchaseEnds('bought');
|
|
128
|
+
const outcome = await subject.port.launchPurchase({
|
|
129
|
+
storeProductId: 'conformance.coins',
|
|
130
|
+
productType: 'one_time_consumable',
|
|
131
|
+
});
|
|
132
|
+
ensure(outcome.ok, `purchase failed: ${JSON.stringify(outcome)}`);
|
|
133
|
+
ensure(
|
|
134
|
+
outcome.purchase.storeProductId === 'conformance.coins',
|
|
135
|
+
'purchase for another product',
|
|
136
|
+
);
|
|
137
|
+
ensure(outcome.purchase.token !== '', 'purchase has no token to finalize');
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: 'reports a cancelled flow as user_cancelled',
|
|
142
|
+
async run(harness) {
|
|
143
|
+
const subject = harness.create(CONFORMANCE_STORE);
|
|
144
|
+
await subject.port.connect();
|
|
145
|
+
subject.nextPurchaseEnds('cancelled');
|
|
146
|
+
const outcome = await subject.port.launchPurchase({
|
|
147
|
+
storeProductId: 'conformance.remove_ads',
|
|
148
|
+
productType: 'one_time_permanent',
|
|
149
|
+
});
|
|
150
|
+
ensure(!outcome.ok, 'a cancelled flow reported a purchase');
|
|
151
|
+
ensure(outcome.reason === 'user_cancelled', `reason ${outcome.reason}`);
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: 'acknowledges and consumes safely twice',
|
|
156
|
+
async run(harness) {
|
|
157
|
+
const { port } = harness.create(CONFORMANCE_STORE);
|
|
158
|
+
await port.connect();
|
|
159
|
+
await port.acknowledge('token-owned');
|
|
160
|
+
await port.acknowledge('token-owned');
|
|
161
|
+
await port.consume('token-pending');
|
|
162
|
+
await port.consume('token-pending');
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
export function runProviderPortConformance(harness: ProviderHarness): Promise<ConformanceReport> {
|
|
168
|
+
return runChecks(() => harness, providerPortChecks);
|
|
169
|
+
}
|
package/src/testing/fakeStore.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* testable at all (NFR-007, SC-008).
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { createListenerSet } from '@chipmobilesdk/rn-core';
|
|
15
|
+
|
|
14
16
|
import type {
|
|
15
17
|
ProviderCapabilities,
|
|
16
18
|
ProviderConnectOutcome,
|
|
@@ -154,8 +156,10 @@ export function createFakeStore(options: FakeStoreOptions = {}): FakeStore {
|
|
|
154
156
|
const acknowledgedTokens: ProviderToken[] = [];
|
|
155
157
|
const consumedTokens: ProviderToken[] = [];
|
|
156
158
|
const managementOpenedFor: (StoreProductId | undefined)[] = [];
|
|
157
|
-
|
|
158
|
-
|
|
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]>();
|
|
159
163
|
|
|
160
164
|
let connectCalls = 0;
|
|
161
165
|
let launchCalls = 0;
|
|
@@ -164,9 +168,7 @@ export function createFakeStore(options: FakeStoreOptions = {}): FakeStore {
|
|
|
164
168
|
|
|
165
169
|
const notifyConnection = (next: boolean, reason?: string): void => {
|
|
166
170
|
connected = next;
|
|
167
|
-
|
|
168
|
-
listener(next, reason);
|
|
169
|
-
}
|
|
171
|
+
connectionListeners.notify(next, reason);
|
|
170
172
|
};
|
|
171
173
|
|
|
172
174
|
return {
|
|
@@ -187,15 +189,7 @@ export function createFakeStore(options: FakeStoreOptions = {}): FakeStore {
|
|
|
187
189
|
},
|
|
188
190
|
|
|
189
191
|
observeConnection(listener): Unsubscribe {
|
|
190
|
-
connectionListeners.add(listener);
|
|
191
|
-
let removed = false;
|
|
192
|
-
return () => {
|
|
193
|
-
if (removed) {
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
removed = true;
|
|
197
|
-
connectionListeners.delete(listener);
|
|
198
|
-
};
|
|
192
|
+
return connectionListeners.add(listener);
|
|
199
193
|
},
|
|
200
194
|
|
|
201
195
|
async queryProducts(
|
|
@@ -235,23 +229,13 @@ export function createFakeStore(options: FakeStoreOptions = {}): FakeStore {
|
|
|
235
229
|
if (androidBinding) {
|
|
236
230
|
// The binding's update listener fires for every observer, the session-long
|
|
237
231
|
// one included, before the launch settles.
|
|
238
|
-
|
|
239
|
-
listener(purchase);
|
|
240
|
-
}
|
|
232
|
+
purchaseListeners.notify(purchase);
|
|
241
233
|
}
|
|
242
234
|
return { ok: true, purchase };
|
|
243
235
|
},
|
|
244
236
|
|
|
245
237
|
observePurchases(listener): Unsubscribe {
|
|
246
|
-
purchaseListeners.add(listener);
|
|
247
|
-
let removed = false;
|
|
248
|
-
return () => {
|
|
249
|
-
if (removed) {
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
removed = true;
|
|
253
|
-
purchaseListeners.delete(listener);
|
|
254
|
-
};
|
|
238
|
+
return purchaseListeners.add(listener);
|
|
255
239
|
},
|
|
256
240
|
|
|
257
241
|
async acknowledge(token: ProviderToken): Promise<void> {
|
|
@@ -306,9 +290,7 @@ export function createFakeStore(options: FakeStoreOptions = {}): FakeStore {
|
|
|
306
290
|
},
|
|
307
291
|
pushPurchaseUpdate(purchase) {
|
|
308
292
|
owned = [...owned.filter(p => p.transactionId !== purchase.transactionId), purchase];
|
|
309
|
-
|
|
310
|
-
listener(purchase);
|
|
311
|
-
}
|
|
293
|
+
purchaseListeners.notify(purchase);
|
|
312
294
|
},
|
|
313
295
|
revoke(transactionId) {
|
|
314
296
|
owned = owned.filter(p => p.transactionId !== transactionId);
|