@chipmobilesdk/rn-payment 0.4.1 → 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 +141 -0
- package/README.md +24 -1
- package/package.json +5 -2
- package/skills/sdk-rn-payment/SKILL.md +4 -2
- package/src/adapter/iap-adapter.ts +35 -38
- package/src/core/mutex.ts +9 -110
- package/src/core/retry.ts +32 -70
- package/src/core/timers.ts +40 -18
- package/src/diagnostics/errors.ts +20 -13
- package/src/diagnostics/events.ts +50 -31
- package/src/diagnostics/messageKeys.ts +15 -9
- package/src/engine/engine.ts +35 -66
- package/src/engine/foreground.ts +29 -0
- package/src/entitlements/aggregate.ts +34 -0
- package/src/index.ts +3 -0
- package/src/persistence/mmkv-store.ts +35 -32
- package/src/react/PaymentProvider.tsx +13 -0
- package/src/react/hooks.ts +10 -31
- package/src/testing/conformance.ts +169 -0
- package/src/testing/fakeStore.ts +11 -29
- package/src/testing/index.ts +9 -0
package/src/core/timers.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* it configured something it did not.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { checkRange, type Range } from '@chipmobilesdk/rn-core';
|
|
15
|
+
|
|
14
16
|
import { PaymentConfigError } from '../diagnostics/errors';
|
|
15
17
|
import type { BackoffConfig, RetryConfig, TimeoutConfig } from '../config/types';
|
|
16
18
|
|
|
@@ -113,30 +115,31 @@ export const DEFAULT_FINALIZE_BACKOFF: Required<BackoffConfig> = {
|
|
|
113
115
|
|
|
114
116
|
export const BACKOFF_MAX_DELAY_BOUNDS = { min: 5_000, max: 600_000 } as const;
|
|
115
117
|
export const BACKOFF_ATTEMPT_BOUNDS = { min: 1, max: 10 } as const;
|
|
118
|
+
/** A first retry sooner than this is a storm; later than this, the user has given up. */
|
|
119
|
+
export const BACKOFF_INITIAL_DELAY_BOUNDS = { min: 100, max: 60_000 } as const;
|
|
120
|
+
/** 1 is a constant delay; 0 or below would retry at once, forever (CORE-05). */
|
|
121
|
+
export const BACKOFF_FACTOR_BOUNDS = { min: 1, max: 10 } as const;
|
|
116
122
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
123
|
+
function invalidConfig(label: string) {
|
|
124
|
+
return (message: string) =>
|
|
125
|
+
new PaymentConfigError({
|
|
126
|
+
code: 'PAYMENT_INVALID_CONFIG',
|
|
127
|
+
message,
|
|
128
|
+
operation: 'configure',
|
|
129
|
+
offenders: [label],
|
|
130
|
+
});
|
|
120
131
|
}
|
|
121
132
|
|
|
122
133
|
function requireInBounds(
|
|
123
134
|
label: string,
|
|
124
135
|
value: number | undefined,
|
|
125
136
|
fallback: number,
|
|
126
|
-
bounds:
|
|
137
|
+
bounds: Range,
|
|
138
|
+
unit = 'ms',
|
|
127
139
|
): number {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (!Number.isFinite(value) || value < bounds.min || value > bounds.max) {
|
|
132
|
-
throw new PaymentConfigError({
|
|
133
|
-
code: 'PAYMENT_INVALID_CATALOG',
|
|
134
|
-
message: `${label} must be between ${bounds.min} and ${bounds.max}ms, received ${value}`,
|
|
135
|
-
operation: 'configure',
|
|
136
|
-
offenders: [label],
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
return value;
|
|
140
|
+
return value === undefined
|
|
141
|
+
? fallback
|
|
142
|
+
: checkRange(label, value, bounds, invalidConfig(label), unit);
|
|
140
143
|
}
|
|
141
144
|
|
|
142
145
|
export interface ResolvedTimeouts {
|
|
@@ -205,11 +208,30 @@ function resolveBackoff(
|
|
|
205
208
|
config?.maxAttempts,
|
|
206
209
|
fallback.maxAttempts,
|
|
207
210
|
BACKOFF_ATTEMPT_BOUNDS,
|
|
211
|
+
'',
|
|
208
212
|
);
|
|
213
|
+
const initialDelayMs = requireInBounds(
|
|
214
|
+
`${label}.initialDelayMs`,
|
|
215
|
+
config?.initialDelayMs,
|
|
216
|
+
fallback.initialDelayMs,
|
|
217
|
+
BACKOFF_INITIAL_DELAY_BOUNDS,
|
|
218
|
+
);
|
|
219
|
+
const factor = requireInBounds(
|
|
220
|
+
`${label}.factor`,
|
|
221
|
+
config?.factor,
|
|
222
|
+
fallback.factor,
|
|
223
|
+
BACKOFF_FACTOR_BOUNDS,
|
|
224
|
+
'',
|
|
225
|
+
);
|
|
226
|
+
if (initialDelayMs > maxDelayMs) {
|
|
227
|
+
throw invalidConfig(`${label}.initialDelayMs`)(
|
|
228
|
+
`${label}.initialDelayMs (${initialDelayMs}ms) exceeds ${label}.maxDelayMs (${maxDelayMs}ms)`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
209
231
|
return {
|
|
210
|
-
initialDelayMs
|
|
232
|
+
initialDelayMs,
|
|
211
233
|
maxDelayMs,
|
|
212
|
-
factor
|
|
234
|
+
factor,
|
|
213
235
|
maxAttempts,
|
|
214
236
|
jitter: config?.jitter ?? fallback.jitter,
|
|
215
237
|
};
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
* internal refactor and must never leak a store's own code.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { REDACTED, SdkError, createRedactor } from '@chipmobilesdk/rn-core';
|
|
10
|
+
|
|
11
|
+
const identifiers = createRedactor();
|
|
12
|
+
|
|
9
13
|
export const PAYMENT_ERROR_CODES = [
|
|
10
14
|
'PAYMENT_NOT_CONFIGURED',
|
|
11
15
|
'PAYMENT_INVALID_CATALOG',
|
|
@@ -31,6 +35,11 @@ export const PAYMENT_ERROR_CODES = [
|
|
|
31
35
|
'PAYMENT_STORAGE_UNAVAILABLE',
|
|
32
36
|
/** `consume` on a transaction that is not a consumable, added in 0.3.2. */
|
|
33
37
|
'PAYMENT_NOT_CONSUMABLE',
|
|
38
|
+
/**
|
|
39
|
+
* A timeout, backoff or interval outside its bounds, added in 0.5.0. These
|
|
40
|
+
* used to be reported as `PAYMENT_INVALID_CATALOG`, which is about products.
|
|
41
|
+
*/
|
|
42
|
+
'PAYMENT_INVALID_CONFIG',
|
|
34
43
|
] as const;
|
|
35
44
|
|
|
36
45
|
export type PaymentErrorCode = (typeof PAYMENT_ERROR_CODES)[number];
|
|
@@ -49,10 +58,9 @@ export type PaymentOperation =
|
|
|
49
58
|
| 'validate'
|
|
50
59
|
| 'persist';
|
|
51
60
|
|
|
52
|
-
|
|
53
|
-
|
|
61
|
+
/** The package's error; an `SdkError` (`@chipmobilesdk/rn-core`) with `pkg: 'rn-payment'`. */
|
|
62
|
+
export class PaymentError extends SdkError<PaymentErrorCode> {
|
|
54
63
|
readonly operation: PaymentOperation;
|
|
55
|
-
readonly retryable: boolean;
|
|
56
64
|
/** Redacted where appropriate — never a raw receipt, token, or store object (FR-074). */
|
|
57
65
|
readonly cause?: string;
|
|
58
66
|
|
|
@@ -63,11 +71,13 @@ export class PaymentError extends Error {
|
|
|
63
71
|
retryable?: boolean;
|
|
64
72
|
cause?: string;
|
|
65
73
|
}) {
|
|
66
|
-
super(init.
|
|
74
|
+
super('rn-payment', init.code, {
|
|
75
|
+
message: init.message,
|
|
76
|
+
retryable: init.retryable ?? false,
|
|
77
|
+
redactedCause: init.cause,
|
|
78
|
+
});
|
|
67
79
|
this.name = 'PaymentError';
|
|
68
|
-
this.code = init.code;
|
|
69
80
|
this.operation = init.operation;
|
|
70
|
-
this.retryable = init.retryable ?? false;
|
|
71
81
|
this.cause = init.cause;
|
|
72
82
|
}
|
|
73
83
|
}
|
|
@@ -107,11 +117,8 @@ export class PaymentConfigError extends PaymentError {
|
|
|
107
117
|
* purchase token or an account id and nothing here can tell which.
|
|
108
118
|
*/
|
|
109
119
|
export function redact(value: unknown): string {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return value.name;
|
|
115
|
-
}
|
|
116
|
-
return '[redacted]';
|
|
120
|
+
// An SDK error reduces to its code and any other Error to its class name
|
|
121
|
+
// (rn-core's identifier-only redactor). Strings never survive here, not even
|
|
122
|
+
// a leading constant: a store message is free text from a payments SDK.
|
|
123
|
+
return value instanceof Error ? identifiers.text(value) : REDACTED;
|
|
117
124
|
}
|
|
@@ -10,6 +10,16 @@
|
|
|
10
10
|
* obfuscated account identifier, and the cache-scope token.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import {
|
|
14
|
+
createDiagnosticChannel,
|
|
15
|
+
createEmitter as createCoreEmitter,
|
|
16
|
+
} from '@chipmobilesdk/rn-core';
|
|
17
|
+
|
|
18
|
+
/** Every delivered event also goes to the app's SDK sinks (`installSdkSink`, CORE-08). */
|
|
19
|
+
const channel = createDiagnosticChannel<PaymentEvent['type'] | 'listener_reporter_failed'>(
|
|
20
|
+
'rn-payment',
|
|
21
|
+
);
|
|
22
|
+
|
|
13
23
|
import { redact, type PaymentErrorCode, type PaymentOperation } from './errors';
|
|
14
24
|
import type {
|
|
15
25
|
EntitlementKey,
|
|
@@ -188,49 +198,58 @@ export interface PaymentEmitter {
|
|
|
188
198
|
export function createEmitter(
|
|
189
199
|
onListenerError?: (error: unknown, event: PaymentEvent) => void,
|
|
190
200
|
): PaymentEmitter {
|
|
191
|
-
|
|
201
|
+
// The event whose listeners are running, so a failure is reported against it.
|
|
202
|
+
let dispatching: PaymentEvent | null = null;
|
|
192
203
|
let isDisposed = false;
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
// delivery must not mutate the set being iterated.
|
|
200
|
-
for (const listener of [...listeners]) {
|
|
204
|
+
// rn-core's emitter: a snapshot per emit, each listener isolated, idempotent
|
|
205
|
+
// unsubscribe. Even the error reporter is isolated: if it throws, rn-core
|
|
206
|
+
// rethrows that on a fresh task rather than into the emit.
|
|
207
|
+
const emitter = createCoreEmitter<PaymentEvent>({
|
|
208
|
+
onListenerError: error => {
|
|
209
|
+
if (!dispatching) return;
|
|
201
210
|
try {
|
|
202
|
-
|
|
203
|
-
} catch
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
211
|
+
onListenerError?.(error, dispatching);
|
|
212
|
+
} catch {
|
|
213
|
+
// The app's own reporter failed: nothing in the app is left to tell, so
|
|
214
|
+
// the failure goes to the SDK sinks rather than becoming an uncaught
|
|
215
|
+
// error on some later tick (FR-080).
|
|
216
|
+
channel.emit('listener_reporter_failed', 'error', {
|
|
217
|
+
eventType: dispatching.type,
|
|
218
|
+
});
|
|
209
219
|
}
|
|
210
|
-
}
|
|
211
|
-
};
|
|
220
|
+
},
|
|
221
|
+
});
|
|
212
222
|
|
|
213
223
|
return {
|
|
214
|
-
emit
|
|
215
|
-
subscribe(listener: PaymentEventListener): Unsubscribe {
|
|
224
|
+
emit(event) {
|
|
216
225
|
if (isDisposed) {
|
|
217
|
-
return
|
|
226
|
+
return;
|
|
218
227
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if (
|
|
223
|
-
|
|
228
|
+
const previous = dispatching;
|
|
229
|
+
dispatching = event;
|
|
230
|
+
try {
|
|
231
|
+
if (emitter.emit(event)) {
|
|
232
|
+
// The type and the cycle only: payment events carry product and
|
|
233
|
+
// entitlement keys an app may treat as personal.
|
|
234
|
+
channel.emit(
|
|
235
|
+
event.type,
|
|
236
|
+
/failed|error|blocked|rejected|revoked/.test(event.type) ? 'warn' : 'info',
|
|
237
|
+
undefined,
|
|
238
|
+
event.correlationId,
|
|
239
|
+
);
|
|
224
240
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
241
|
+
} finally {
|
|
242
|
+
dispatching = previous;
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
subscribe(listener: PaymentEventListener): Unsubscribe {
|
|
246
|
+
return isDisposed ? () => undefined : emitter.subscribe(listener);
|
|
228
247
|
},
|
|
229
248
|
dispose(): void {
|
|
230
249
|
isDisposed = true;
|
|
231
|
-
|
|
250
|
+
emitter.dispose();
|
|
232
251
|
},
|
|
233
|
-
listenerCount: () =>
|
|
252
|
+
listenerCount: () => emitter.listenerCount(),
|
|
234
253
|
disposed: () => isDisposed,
|
|
235
254
|
};
|
|
236
255
|
}
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
/** Copy an end user may reasonably be shown. */
|
|
17
|
+
import { defineMessages } from '@chipmobilesdk/rn-core';
|
|
18
|
+
|
|
17
19
|
export interface PaymentUserMessageMap {
|
|
18
20
|
'payment.state.pending': string;
|
|
19
21
|
'payment.state.alreadyOwned': string;
|
|
@@ -68,20 +70,24 @@ export const DEFAULT_DEVELOPER_MESSAGES: PaymentDeveloperMessageMap = {
|
|
|
68
70
|
'Purchase blocked: this environment may not create a real transaction',
|
|
69
71
|
};
|
|
70
72
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
/**
|
|
74
|
+
* The catalog as an rn-core message set. Register `paymentMessages.user` with
|
|
75
|
+
* rn-i18n to translate it like app copy; the developer copy stays out of bundles.
|
|
76
|
+
*/
|
|
77
|
+
export const paymentMessages = defineMessages({
|
|
78
|
+
user: DEFAULT_USER_MESSAGES,
|
|
79
|
+
developer: DEFAULT_DEVELOPER_MESSAGES,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export const DEFAULT_MESSAGES: PaymentMessageMap = paymentMessages.defaults;
|
|
75
83
|
|
|
76
84
|
/** Every user-actionable key, for an app building its translation resource. */
|
|
77
|
-
export const USER_MESSAGE_KEYS =
|
|
85
|
+
export const USER_MESSAGE_KEYS = paymentMessages.userKeys as PaymentUserMessageKey[];
|
|
78
86
|
|
|
79
87
|
/** Every developer-facing key, so an app can exclude them from user-visible bundles. */
|
|
80
|
-
export const DEVELOPER_MESSAGE_KEYS =
|
|
81
|
-
DEFAULT_DEVELOPER_MESSAGES,
|
|
82
|
-
) as PaymentDeveloperMessageKey[];
|
|
88
|
+
export const DEVELOPER_MESSAGE_KEYS = paymentMessages.developerKeys as PaymentDeveloperMessageKey[];
|
|
83
89
|
|
|
84
90
|
/** True for keys safe to show an end user. Lets an app assert that at its boundary. */
|
|
85
91
|
export function isUserMessageKey(key: PaymentMessageKey): key is PaymentUserMessageKey {
|
|
86
|
-
return key
|
|
92
|
+
return paymentMessages.audienceOf(key) === 'user';
|
|
87
93
|
}
|
package/src/engine/engine.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* which is what keeps the binding replaceable and iOS additive (FR-002, FR-007).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { createListenerSet, currentPlatform } from '@chipmobilesdk/rn-core';
|
|
14
|
+
|
|
13
15
|
import {
|
|
14
16
|
assertEnvironmentSafe,
|
|
15
17
|
mayCreateRealTransaction,
|
|
@@ -60,6 +62,7 @@ import {
|
|
|
60
62
|
initialEntitlements,
|
|
61
63
|
type EntitlementMap,
|
|
62
64
|
type EntitlementState,
|
|
65
|
+
shareUnchanged,
|
|
63
66
|
} from '../entitlements/aggregate';
|
|
64
67
|
import { productKeyForStoreId } from '../entitlements/mapping';
|
|
65
68
|
import { derivePending, pendingTransitions, samePending, UNKNOWN_PENDING } from './pending';
|
|
@@ -156,7 +159,10 @@ export function createEngine(config: PaymentConfig, deps: EngineDeps): PaymentEn
|
|
|
156
159
|
// Both run before anything connects: a bad catalog or an unsafe environment
|
|
157
160
|
// must fail at configuration time, not when a user taps Buy.
|
|
158
161
|
assertEnvironmentSafe(config);
|
|
159
|
-
|
|
162
|
+
// rn-core's one platform source (CORE-09): this used to assume Android, so an
|
|
163
|
+
// iOS app validated its catalog against the Android product ids. Where neither
|
|
164
|
+
// OS applies, the store provider refuses everything as unsupported anyway.
|
|
165
|
+
const platform = deps.platform ?? (currentPlatform() === 'ios' ? 'ios' : 'android');
|
|
160
166
|
const resolved = validateConfig(config, platform);
|
|
161
167
|
|
|
162
168
|
const now = deps.now ?? (() => Date.now());
|
|
@@ -190,9 +196,15 @@ export function createEngine(config: PaymentConfig, deps: EngineDeps): PaymentEn
|
|
|
190
196
|
|
|
191
197
|
let pending: PendingPurchasesState = UNKNOWN_PENDING;
|
|
192
198
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const
|
|
199
|
+
// A broken consumer callback must not stop the others nor the state machine
|
|
200
|
+
// (FR-081); rn-core isolates each one and the failure goes to the logger.
|
|
201
|
+
const listenerOptions = {
|
|
202
|
+
onListenerError: (error: unknown) =>
|
|
203
|
+
config.logger?.error?.('payment listener threw', { reason: redact(error) }),
|
|
204
|
+
};
|
|
205
|
+
const statusListeners = createListenerSet<[PaymentStatus]>(listenerOptions);
|
|
206
|
+
const entitlementListeners = createListenerSet<[EntitlementMap]>(listenerOptions);
|
|
207
|
+
const pendingListeners = createListenerSet<[PendingPurchasesState]>(listenerOptions);
|
|
196
208
|
let connectionSub: Unsubscribe | undefined;
|
|
197
209
|
let purchaseSub: Unsubscribe | undefined;
|
|
198
210
|
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -222,14 +234,7 @@ export function createEngine(config: PaymentConfig, deps: EngineDeps): PaymentEn
|
|
|
222
234
|
unavailableReason: status.unavailableReason,
|
|
223
235
|
retryable: status.retryable,
|
|
224
236
|
});
|
|
225
|
-
|
|
226
|
-
// A throwing status listener must not break the state machine (FR-081).
|
|
227
|
-
try {
|
|
228
|
-
listener(status);
|
|
229
|
-
} catch {
|
|
230
|
-
/* reported through the emitter's own guard */
|
|
231
|
-
}
|
|
232
|
-
}
|
|
237
|
+
statusListeners.notify(status);
|
|
233
238
|
};
|
|
234
239
|
|
|
235
240
|
/**
|
|
@@ -244,13 +249,16 @@ export function createEngine(config: PaymentConfig, deps: EngineDeps): PaymentEn
|
|
|
244
249
|
return;
|
|
245
250
|
}
|
|
246
251
|
const previous = entitlements;
|
|
247
|
-
entitlements =
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
252
|
+
entitlements = shareUnchanged(
|
|
253
|
+
previous,
|
|
254
|
+
aggregate({
|
|
255
|
+
config: resolved,
|
|
256
|
+
snapshot: currentSnapshot,
|
|
257
|
+
hasSynced,
|
|
258
|
+
reportsGraceAndHoldDistinctly: provider.capabilities().reportsGraceAndHoldDistinctly,
|
|
259
|
+
now: now(),
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
254
262
|
|
|
255
263
|
for (const change of diffEntitlements(previous, entitlements)) {
|
|
256
264
|
if (change.next === 'entitled') {
|
|
@@ -268,12 +276,9 @@ export function createEngine(config: PaymentConfig, deps: EngineDeps): PaymentEn
|
|
|
268
276
|
}
|
|
269
277
|
}
|
|
270
278
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
} catch {
|
|
275
|
-
/* a broken consumer callback must not stop the others (FR-081) */
|
|
276
|
-
}
|
|
279
|
+
// Unchanged map, same object: nothing to tell a subscriber (PERF-03).
|
|
280
|
+
if (entitlements !== previous) {
|
|
281
|
+
entitlementListeners.notify(entitlements);
|
|
277
282
|
}
|
|
278
283
|
|
|
279
284
|
publishPending(
|
|
@@ -298,13 +303,7 @@ export function createEngine(config: PaymentConfig, deps: EngineDeps): PaymentEn
|
|
|
298
303
|
return;
|
|
299
304
|
}
|
|
300
305
|
pending = next;
|
|
301
|
-
|
|
302
|
-
try {
|
|
303
|
-
listener(pending);
|
|
304
|
-
} catch {
|
|
305
|
-
/* a broken consumer callback must not stop the others (FR-081) */
|
|
306
|
-
}
|
|
307
|
-
}
|
|
306
|
+
pendingListeners.notify(pending);
|
|
308
307
|
};
|
|
309
308
|
|
|
310
309
|
const commit = (next: PersistedSnapshot): void => {
|
|
@@ -668,15 +667,7 @@ export function createEngine(config: PaymentConfig, deps: EngineDeps): PaymentEn
|
|
|
668
667
|
if (disposed) {
|
|
669
668
|
return () => undefined;
|
|
670
669
|
}
|
|
671
|
-
statusListeners.add(listener);
|
|
672
|
-
let removed = false;
|
|
673
|
-
return () => {
|
|
674
|
-
if (removed) {
|
|
675
|
-
return;
|
|
676
|
-
}
|
|
677
|
-
removed = true;
|
|
678
|
-
statusListeners.delete(listener);
|
|
679
|
-
};
|
|
670
|
+
return statusListeners.add(listener);
|
|
680
671
|
},
|
|
681
672
|
|
|
682
673
|
/** Synchronous, in-memory, zero I/O — it is read on render paths (PERF-001). */
|
|
@@ -699,30 +690,14 @@ export function createEngine(config: PaymentConfig, deps: EngineDeps): PaymentEn
|
|
|
699
690
|
if (disposed) {
|
|
700
691
|
return () => undefined;
|
|
701
692
|
}
|
|
702
|
-
pendingListeners.add(listener);
|
|
703
|
-
let removed = false;
|
|
704
|
-
return () => {
|
|
705
|
-
if (removed) {
|
|
706
|
-
return;
|
|
707
|
-
}
|
|
708
|
-
removed = true;
|
|
709
|
-
pendingListeners.delete(listener);
|
|
710
|
-
};
|
|
693
|
+
return pendingListeners.add(listener);
|
|
711
694
|
},
|
|
712
695
|
|
|
713
696
|
subscribeEntitlements(listener): Unsubscribe {
|
|
714
697
|
if (disposed) {
|
|
715
698
|
return () => undefined;
|
|
716
699
|
}
|
|
717
|
-
entitlementListeners.add(listener);
|
|
718
|
-
let removed = false;
|
|
719
|
-
return () => {
|
|
720
|
-
if (removed) {
|
|
721
|
-
return;
|
|
722
|
-
}
|
|
723
|
-
removed = true;
|
|
724
|
-
entitlementListeners.delete(listener);
|
|
725
|
-
};
|
|
700
|
+
return entitlementListeners.add(listener);
|
|
726
701
|
},
|
|
727
702
|
|
|
728
703
|
async sync(trigger: SyncTrigger = 'manual'): Promise<SyncOutcome> {
|
|
@@ -862,13 +837,7 @@ export function createEngine(config: PaymentConfig, deps: EngineDeps): PaymentEn
|
|
|
862
837
|
// Back to `unknown`, not `not_entitled`: after a logout the package has no
|
|
863
838
|
// basis for either claim until the next sync.
|
|
864
839
|
entitlements = initialEntitlements(resolved);
|
|
865
|
-
|
|
866
|
-
try {
|
|
867
|
-
listener(entitlements);
|
|
868
|
-
} catch {
|
|
869
|
-
/* ignore */
|
|
870
|
-
}
|
|
871
|
-
}
|
|
840
|
+
entitlementListeners.notify(entitlements);
|
|
872
841
|
// Same reasoning, and no `purchase_pending_resolved`: the records were
|
|
873
842
|
// discarded locally, not ended at the store.
|
|
874
843
|
publishPending(UNKNOWN_PENDING);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in foreground sync over rn-core's shared lifecycle port (PERF-07, FR-059).
|
|
3
|
+
*
|
|
4
|
+
* `handleForeground()` stays the app's to call: the engine does not decide what a
|
|
5
|
+
* foreground return is. An app that is happy with "back from the background" calls
|
|
6
|
+
* this once instead of wiring `AppState` itself, and shares the one native
|
|
7
|
+
* subscription every package uses. A trip through `inactive` alone (a biometric
|
|
8
|
+
* prompt, iOS Control Center) is not a return. On Android the Play purchase sheet
|
|
9
|
+
* does send the app to the background, so a purchase is followed by a sync; the
|
|
10
|
+
* engine's foreground rate limit keeps that to one request per interval.
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
appLifecycle,
|
|
14
|
+
onForeground,
|
|
15
|
+
type AppLifecyclePort,
|
|
16
|
+
type Unsubscribe,
|
|
17
|
+
} from '@chipmobilesdk/rn-core';
|
|
18
|
+
|
|
19
|
+
import type { PaymentEngine } from './engine';
|
|
20
|
+
|
|
21
|
+
export function syncOnForeground(
|
|
22
|
+
engine: Pick<PaymentEngine, 'handleForeground'>,
|
|
23
|
+
lifecycle: AppLifecyclePort = appLifecycle(),
|
|
24
|
+
): Unsubscribe {
|
|
25
|
+
return onForeground(() => {
|
|
26
|
+
// Sync reports its own failures through status and diagnostics.
|
|
27
|
+
engine.handleForeground().catch(() => undefined);
|
|
28
|
+
}, lifecycle);
|
|
29
|
+
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* and the package says so rather than asserting the user did not pay.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { shallowEqual } from '@chipmobilesdk/rn-core';
|
|
19
20
|
import type { ResolvedConfig } from '../config/validate';
|
|
20
21
|
import type { EntitlementKey, ProductKey } from '../config/types';
|
|
21
22
|
import type { PersistedPurchase, PersistedSnapshot } from '../persistence/snapshot';
|
|
@@ -225,3 +226,36 @@ export function diffEntitlements(
|
|
|
225
226
|
}
|
|
226
227
|
return changes;
|
|
227
228
|
}
|
|
229
|
+
|
|
230
|
+
function sameEntitlement(a: EntitlementState, b: EntitlementState): boolean {
|
|
231
|
+
return (
|
|
232
|
+
a.status === b.status &&
|
|
233
|
+
a.isStale === b.isStale &&
|
|
234
|
+
a.lastSyncedAt === b.lastSyncedAt &&
|
|
235
|
+
a.sources.length === b.sources.length &&
|
|
236
|
+
a.sources.every((source, index) => source === b.sources[index]) &&
|
|
237
|
+
shallowEqual(a.subscription, b.subscription)
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* `next`, with every entitlement that did not change replaced by the object it
|
|
243
|
+
* replaces, and `previous` itself when nothing changed (PERF-03, PAY-08). A hook
|
|
244
|
+
* selecting one entitlement then sees the same object and does not re-render.
|
|
245
|
+
*/
|
|
246
|
+
export function shareUnchanged(previous: EntitlementMap, next: EntitlementMap): EntitlementMap {
|
|
247
|
+
const keys = Object.keys(next);
|
|
248
|
+
let changed = keys.length !== Object.keys(previous).length;
|
|
249
|
+
const shared: EntitlementMap = {};
|
|
250
|
+
for (const key of keys) {
|
|
251
|
+
const before = previous[key];
|
|
252
|
+
const after = next[key]!;
|
|
253
|
+
if (before && sameEntitlement(before, after)) {
|
|
254
|
+
shared[key] = before;
|
|
255
|
+
} else {
|
|
256
|
+
shared[key] = after;
|
|
257
|
+
changed = true;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return changed ? shared : previous;
|
|
261
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -120,6 +120,8 @@ export type {
|
|
|
120
120
|
SyncOutcome,
|
|
121
121
|
} from './engine/engine';
|
|
122
122
|
|
|
123
|
+
export { syncOnForeground } from './engine/foreground';
|
|
124
|
+
|
|
123
125
|
// --- React -----------------------------------------------------------------
|
|
124
126
|
export { PaymentProvider, usePaymentEngine } from './react/PaymentProvider';
|
|
125
127
|
export type { PaymentProviderProps } from './react/PaymentProvider';
|
|
@@ -150,6 +152,7 @@ export type {
|
|
|
150
152
|
export {
|
|
151
153
|
DEFAULT_DEVELOPER_MESSAGES,
|
|
152
154
|
DEFAULT_MESSAGES,
|
|
155
|
+
paymentMessages,
|
|
153
156
|
DEFAULT_USER_MESSAGES,
|
|
154
157
|
DEVELOPER_MESSAGE_KEYS,
|
|
155
158
|
USER_MESSAGE_KEYS,
|
|
@@ -14,19 +14,21 @@
|
|
|
14
14
|
* attached to a transaction.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { createMemoryKeyValue, lazyPeer, openDurable, type MmkvLike } from '@chipmobilesdk/rn-core';
|
|
18
|
+
|
|
17
19
|
import { emptySnapshot, migrateSnapshot, type PersistedSnapshot } from './snapshot';
|
|
18
20
|
|
|
19
21
|
const INSTANCE_ID = 'chipmobilesdk.payment';
|
|
20
22
|
const KEY_PREFIX = 'snapshot';
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
24
|
+
const mmkv = lazyPeer('react-native-mmkv', () => {
|
|
25
|
+
// Inside `try`, so Metro bundles an app that leaves this optional peer out.
|
|
26
|
+
try {
|
|
27
|
+
return require('react-native-mmkv') as { createMMKV: (config?: { id?: string }) => MmkvLike };
|
|
28
|
+
} catch (cause) {
|
|
29
|
+
throw cause;
|
|
30
|
+
}
|
|
31
|
+
});
|
|
30
32
|
|
|
31
33
|
export interface SnapshotStore {
|
|
32
34
|
load(scopeToken?: string): PersistedSnapshot;
|
|
@@ -43,20 +45,6 @@ function scopeKey(scopeToken?: string): string {
|
|
|
43
45
|
return `${KEY_PREFIX}:${scopeToken ?? '__default__'}`;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
/** In-memory fallback, used when the native module is absent. */
|
|
47
|
-
function createMemoryBacking(): MmkvLike {
|
|
48
|
-
const map = new Map<string, string>();
|
|
49
|
-
return {
|
|
50
|
-
getString: key => map.get(key),
|
|
51
|
-
set(key, value) {
|
|
52
|
-
map.set(key, value);
|
|
53
|
-
},
|
|
54
|
-
remove: key => map.delete(key),
|
|
55
|
-
clearAll: () => map.clear(),
|
|
56
|
-
getAllKeys: () => [...map.keys()],
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
48
|
/**
|
|
61
49
|
* Opens the MMKV instance through v4's `createMMKV`.
|
|
62
50
|
*
|
|
@@ -67,15 +55,20 @@ function createMemoryBacking(): MmkvLike {
|
|
|
67
55
|
* Tests could not see it, because the test double still exported a v3 class.
|
|
68
56
|
*/
|
|
69
57
|
function resolveBacking(): { backing: MmkvLike; durable: boolean } {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
58
|
+
// rn-core's port: MMKV when it opens, otherwise memory with `durable: false`.
|
|
59
|
+
const port = openDurable(() =>
|
|
60
|
+
mmkv.require(peer => new Error(`${peer} is not installed`)).createMMKV({ id: INSTANCE_ID }),
|
|
61
|
+
);
|
|
62
|
+
return {
|
|
63
|
+
backing: {
|
|
64
|
+
getString: key => port.getString(key),
|
|
65
|
+
set: (key, value) => port.set(key, value),
|
|
66
|
+
remove: key => port.remove(key),
|
|
67
|
+
clearAll: () => port.clearAll(),
|
|
68
|
+
getAllKeys: () => port.keys(),
|
|
69
|
+
},
|
|
70
|
+
durable: port.durable,
|
|
71
|
+
};
|
|
79
72
|
}
|
|
80
73
|
|
|
81
74
|
/**
|
|
@@ -151,5 +144,15 @@ export function createSnapshotStore(injected?: {
|
|
|
151
144
|
|
|
152
145
|
/** Test seam for driving the store without the native module. */
|
|
153
146
|
export function createMemorySnapshotStore(): SnapshotStore {
|
|
154
|
-
|
|
147
|
+
const memory = createMemoryKeyValue();
|
|
148
|
+
return createSnapshotStore({
|
|
149
|
+
backing: {
|
|
150
|
+
getString: key => memory.getString(key),
|
|
151
|
+
set: (key, value) => memory.set(key, value),
|
|
152
|
+
remove: key => memory.remove(key),
|
|
153
|
+
clearAll: () => memory.clearAll(),
|
|
154
|
+
getAllKeys: () => memory.keys(),
|
|
155
|
+
},
|
|
156
|
+
durable: false,
|
|
157
|
+
});
|
|
155
158
|
}
|