@chipmobilesdk/rn-payment 0.5.0 → 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 +61 -0
- package/README.md +46 -15
- package/package.json +1 -1
- package/skills/sdk-rn-payment/SKILL.md +4 -4
- package/src/adapter/iap-adapter.ts +12 -216
- package/src/adapter/iapShapes.ts +232 -0
- package/src/config/types.ts +8 -0
- package/src/config/validate.ts +19 -4
- package/src/core/retry.ts +1 -58
- package/src/diagnostics/events.ts +1 -1
- package/src/engine/connection.ts +194 -0
- package/src/engine/engine.ts +140 -700
- 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 +3 -5
- package/src/entitlements/mapping.ts +0 -22
- package/src/persistence/mmkv-store.ts +10 -3
- package/src/persistence/snapshot.ts +102 -27
- package/src/react/PaymentProvider.tsx +73 -24
- package/src/react/hooks.ts +13 -3
package/src/engine/engine.ts
CHANGED
|
@@ -8,15 +8,19 @@
|
|
|
8
8
|
*
|
|
9
9
|
* It never imports a store type. The only thing it talks to is `ProviderPort`,
|
|
10
10
|
* which is what keeps the binding replaceable and iOS additive (FR-002, FR-007).
|
|
11
|
+
*
|
|
12
|
+
* This file composes (PAY-10): state and its publication live in `state.ts`, the
|
|
13
|
+
* connection in `connection.ts`, sync in `sync.ts`, and the internals tests reach
|
|
14
|
+
* for in `internals.ts`, off the public type (PAY-11).
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
|
-
import {
|
|
17
|
+
import { currentPlatform } from '@chipmobilesdk/rn-core';
|
|
14
18
|
|
|
19
|
+
import type { ProviderPort } from '../adapter/port';
|
|
15
20
|
import {
|
|
16
21
|
assertEnvironmentSafe,
|
|
17
22
|
mayCreateRealTransaction,
|
|
18
23
|
validateConfig,
|
|
19
|
-
type ResolvedConfig,
|
|
20
24
|
type TargetPlatform,
|
|
21
25
|
} from '../config/validate';
|
|
22
26
|
import type {
|
|
@@ -33,43 +37,26 @@ import type {
|
|
|
33
37
|
UnfinalizedSummary,
|
|
34
38
|
Unsubscribe,
|
|
35
39
|
} from '../config/types';
|
|
36
|
-
import {
|
|
40
|
+
import { createExclusive, createSingleFlight } from '../core/mutex';
|
|
41
|
+
import { PaymentError } from '../diagnostics/errors';
|
|
37
42
|
import {
|
|
38
43
|
createCorrelationId,
|
|
39
|
-
createEmitter,
|
|
40
|
-
type PaymentEmitter,
|
|
41
|
-
type PaymentEvent,
|
|
42
|
-
type PaymentEventInput,
|
|
43
44
|
type PaymentEventListener,
|
|
44
45
|
type SyncTrigger,
|
|
45
46
|
} from '../diagnostics/events';
|
|
46
|
-
import {
|
|
47
|
+
import type { EntitlementMap, EntitlementState } from '../entitlements/aggregate';
|
|
48
|
+
import { createSnapshotStore, type SnapshotStore } from '../persistence/mmkv-store';
|
|
49
|
+
import { createConnection, type InitOutcome } from './connection';
|
|
47
50
|
import { createFinalizer } from './finalize';
|
|
51
|
+
import { attachEngineInternals } from './internals';
|
|
48
52
|
import { createProducts, type ProductQueryResult } from './products';
|
|
49
53
|
import { createPurchaseFlow } from './purchase';
|
|
54
|
+
import { createEngineState } from './state';
|
|
50
55
|
import { createSubscriptions, type PriceChangeResult, type ProrationMode } from './subscriptions';
|
|
51
|
-
import {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
resolveFinalizeState,
|
|
56
|
-
type PersistedPurchase,
|
|
57
|
-
type PersistedSnapshot,
|
|
58
|
-
} from '../persistence/snapshot';
|
|
59
|
-
import {
|
|
60
|
-
aggregate,
|
|
61
|
-
diffEntitlements,
|
|
62
|
-
initialEntitlements,
|
|
63
|
-
type EntitlementMap,
|
|
64
|
-
type EntitlementState,
|
|
65
|
-
shareUnchanged,
|
|
66
|
-
} from '../entitlements/aggregate';
|
|
67
|
-
import { productKeyForStoreId } from '../entitlements/mapping';
|
|
68
|
-
import { derivePending, pendingTransitions, samePending, UNKNOWN_PENDING } from './pending';
|
|
69
|
-
import type { ProviderPort, ProviderPurchase } from '../adapter/port';
|
|
70
|
-
|
|
71
|
-
export type SyncOutcome =
|
|
72
|
-
{ ok: true; restoredCount: number; revokedCount: number } | { ok: false; error: PaymentError };
|
|
56
|
+
import { createSync, type SyncOutcome } from './sync';
|
|
57
|
+
|
|
58
|
+
export type { SyncOutcome } from './sync';
|
|
59
|
+
export type { InitOutcome } from './connection';
|
|
73
60
|
|
|
74
61
|
export type RestoreOutcome =
|
|
75
62
|
| { ok: true; restored: true; entitlements: EntitlementKey[] }
|
|
@@ -77,8 +64,6 @@ export type RestoreOutcome =
|
|
|
77
64
|
| { ok: true; restored: false }
|
|
78
65
|
| { ok: false; error: PaymentError };
|
|
79
66
|
|
|
80
|
-
export type InitOutcome = { ok: true } | { ok: false; error: PaymentError };
|
|
81
|
-
|
|
82
67
|
export interface EngineDeps {
|
|
83
68
|
/** Injected so the fake store is a drop-in replacement (NFR-007). */
|
|
84
69
|
provider: ProviderPort;
|
|
@@ -112,14 +97,6 @@ export interface PaymentEngine {
|
|
|
112
97
|
clearLocalData(): Promise<void>;
|
|
113
98
|
|
|
114
99
|
subscribeEvents(listener: PaymentEventListener): Unsubscribe;
|
|
115
|
-
/** Exposed for diagnostics and tests; not part of the public package surface. */
|
|
116
|
-
resolvedConfig(): ResolvedConfig;
|
|
117
|
-
nextCorrelationId(): string;
|
|
118
|
-
emitter(): PaymentEmitter;
|
|
119
|
-
snapshot(): PersistedSnapshot;
|
|
120
|
-
commitSnapshot(next: PersistedSnapshot): void;
|
|
121
|
-
mayCreateRealTransaction(): boolean;
|
|
122
|
-
recordBlockedOperation(productKey: ProductKey): void;
|
|
123
100
|
|
|
124
101
|
// --- Purchase and finalization (US2, US3, US5) ---------------------------
|
|
125
102
|
purchase(productKey: ProductKey, options?: PurchaseOptions): Promise<PurchaseResult>;
|
|
@@ -155,391 +132,109 @@ export interface PaymentEngine {
|
|
|
155
132
|
handleForeground(): Promise<void>;
|
|
156
133
|
}
|
|
157
134
|
|
|
135
|
+
/** How many refused operations `status.blockedOperations` keeps. */
|
|
136
|
+
export const MAX_BLOCKED_OPERATIONS = 20;
|
|
137
|
+
|
|
158
138
|
export function createEngine(config: PaymentConfig, deps: EngineDeps): PaymentEngine {
|
|
159
139
|
// Both run before anything connects: a bad catalog or an unsafe environment
|
|
160
140
|
// must fail at configuration time, not when a user taps Buy.
|
|
161
141
|
assertEnvironmentSafe(config);
|
|
162
|
-
// rn-core's one platform source (CORE-09)
|
|
163
|
-
//
|
|
164
|
-
// OS applies, the store provider refuses everything as unsupported anyway.
|
|
142
|
+
// rn-core's one platform source (CORE-09). Where neither OS applies, the store
|
|
143
|
+
// provider refuses everything as unsupported anyway.
|
|
165
144
|
const platform = deps.platform ?? (currentPlatform() === 'ios' ? 'ios' : 'android');
|
|
166
145
|
const resolved = validateConfig(config, platform);
|
|
167
|
-
|
|
168
146
|
const now = deps.now ?? (() => Date.now());
|
|
169
147
|
const provider = deps.provider;
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
});
|
|
179
|
-
});
|
|
148
|
+
const state = createEngineState(
|
|
149
|
+
config,
|
|
150
|
+
resolved,
|
|
151
|
+
provider,
|
|
152
|
+
deps.snapshotStore ?? createSnapshotStore(),
|
|
153
|
+
now,
|
|
154
|
+
);
|
|
155
|
+
const { emit, commit } = state;
|
|
180
156
|
const correlationCounter = { next: 1 };
|
|
181
|
-
|
|
157
|
+
const nextCorrelationId = () => createCorrelationId(correlationCounter);
|
|
182
158
|
const initFlight = createSingleFlight<InitOutcome>();
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const settleFlight = createSingleFlight<void>();
|
|
186
|
-
const foregroundLimiter = createRateLimiter(resolved.foregroundSyncMinIntervalMs, now);
|
|
187
|
-
|
|
188
|
-
let disposed = false;
|
|
189
|
-
let hasSynced = false;
|
|
190
|
-
let currentSnapshot: PersistedSnapshot = emptySnapshot(config.scopeToken);
|
|
191
|
-
let entitlements: EntitlementMap = initialEntitlements(resolved);
|
|
192
|
-
let status: PaymentStatus = {
|
|
193
|
-
kind: resolved.enabled ? 'not_started' : 'disabled',
|
|
194
|
-
blockedOperations: [],
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
let pending: PendingPurchasesState = UNKNOWN_PENDING;
|
|
198
|
-
|
|
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);
|
|
208
|
-
let connectionSub: Unsubscribe | undefined;
|
|
209
|
-
let purchaseSub: Unsubscribe | undefined;
|
|
210
|
-
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
211
|
-
let reconnectAttempts = 0;
|
|
212
|
-
/**
|
|
213
|
-
* Set once a connection has succeeded and the watchers are attached. Recovery
|
|
214
|
-
* after an exhausted reconnect budget is only meaningful from there; a first
|
|
215
|
-
* connection that failed is the app's to retry through `initialize`.
|
|
216
|
-
*/
|
|
217
|
-
let everConnected = false;
|
|
218
|
-
// Exclusive rather than single-flight: a second purchase must be refused, not
|
|
219
|
-
// joined onto the first, and never queued (FR-031).
|
|
159
|
+
// Exclusive rather than single-flight: a second purchase is refused, not joined
|
|
160
|
+
// onto the first, and never queued (FR-031).
|
|
220
161
|
const purchaseLock = createExclusive();
|
|
221
162
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
emit({
|
|
232
|
-
type: 'initialization',
|
|
233
|
-
status: status.kind,
|
|
234
|
-
unavailableReason: status.unavailableReason,
|
|
235
|
-
retryable: status.retryable,
|
|
163
|
+
/** Written into status, not just logged, so a test can assert the block (FR-014). */
|
|
164
|
+
const recordBlockedOperation = (productKey: ProductKey): void => {
|
|
165
|
+
const blocked: BlockedOperation = { productKey, environment: config.environment, at: now() };
|
|
166
|
+
// The most recent ones only: an app retrying a blocked purchase in a loop
|
|
167
|
+
// must not grow status without bound (PAY-20).
|
|
168
|
+
state.setStatus({
|
|
169
|
+
blockedOperations: [...state.status.blockedOperations, blocked].slice(
|
|
170
|
+
-MAX_BLOCKED_OPERATIONS,
|
|
171
|
+
),
|
|
236
172
|
});
|
|
237
|
-
|
|
238
|
-
};
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Recomputes entitlements and emits only genuine transitions.
|
|
242
|
-
*
|
|
243
|
-
* Diff-based emission is what gives the exactly-once guarantees: a renewal
|
|
244
|
-
* that changes nothing produces no event (FR-068), and an entitlement with
|
|
245
|
-
* several sources fires once, not once per source (FR-079).
|
|
246
|
-
*/
|
|
247
|
-
const recompute = (): void => {
|
|
248
|
-
if (disposed) {
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
const previous = entitlements;
|
|
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
|
-
);
|
|
262
|
-
|
|
263
|
-
for (const change of diffEntitlements(previous, entitlements)) {
|
|
264
|
-
if (change.next === 'entitled') {
|
|
265
|
-
emit({
|
|
266
|
-
type: 'entitlement_granted',
|
|
267
|
-
entitlementKey: change.key,
|
|
268
|
-
sources: change.sources,
|
|
269
|
-
});
|
|
270
|
-
} else if (change.previous === 'entitled') {
|
|
271
|
-
emit({
|
|
272
|
-
type: 'entitlement_revoked',
|
|
273
|
-
entitlementKey: change.key,
|
|
274
|
-
reason: 'revoked_by_store',
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// Unchanged map, same object: nothing to tell a subscriber (PERF-03).
|
|
280
|
-
if (entitlements !== previous) {
|
|
281
|
-
entitlementListeners.notify(entitlements);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
publishPending(
|
|
285
|
-
derivePending({
|
|
286
|
-
config: resolved,
|
|
287
|
-
snapshot: currentSnapshot,
|
|
288
|
-
hasSynced,
|
|
289
|
-
now: now(),
|
|
290
|
-
}),
|
|
291
|
-
);
|
|
292
|
-
};
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Hands listeners the pending set, but only when it changed (FR-101).
|
|
296
|
-
*
|
|
297
|
-
* Unlike entitlements this is diffed, because a sync that changes nothing is
|
|
298
|
-
* the common case and an app rendering "payment pending" has no use for a
|
|
299
|
-
* re-render that says the same thing.
|
|
300
|
-
*/
|
|
301
|
-
const publishPending = (next: PendingPurchasesState): void => {
|
|
302
|
-
if (samePending(pending, next)) {
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
pending = next;
|
|
306
|
-
pendingListeners.notify(pending);
|
|
307
|
-
};
|
|
308
|
-
|
|
309
|
-
const commit = (next: PersistedSnapshot): void => {
|
|
310
|
-
// Read before the swap: a transition is "pending before, not pending now",
|
|
311
|
-
// and only the commit sees both sides (FR-104).
|
|
312
|
-
const ended = pendingTransitions(currentSnapshot, next, resolved, transactionId =>
|
|
313
|
-
provider.pendingEndReason?.(transactionId),
|
|
314
|
-
);
|
|
315
|
-
currentSnapshot = next;
|
|
316
|
-
store.save(next);
|
|
317
|
-
// Before `recompute`, so `settled` precedes the `entitlement_granted` it
|
|
318
|
-
// causes — an app that hides "payment pending" on the first event never
|
|
319
|
-
// shows the grant and the pending row at once (FR-102).
|
|
320
|
-
for (const transition of ended) {
|
|
321
|
-
emit({
|
|
322
|
-
type: 'purchase_pending_resolved',
|
|
323
|
-
productKey: transition.productKey,
|
|
324
|
-
outcome: transition.outcome,
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
recompute();
|
|
328
|
-
};
|
|
329
|
-
|
|
330
|
-
/**
|
|
331
|
-
* Loads the persisted snapshot and makes it trustworthy.
|
|
332
|
-
*
|
|
333
|
-
* Shared by both initialization paths so they cannot drift. `hasSynced` is the
|
|
334
|
-
* subtle part: a record carrying a `lastSyncedAt` was written by a sync that
|
|
335
|
-
* genuinely succeeded, so it is a basis for asserting entitlement. Without
|
|
336
|
-
* setting it, a build with selling disabled would report `unknown` forever,
|
|
337
|
-
* because no sync will ever run to mark the snapshot good (FR-012, FR-053).
|
|
338
|
-
*/
|
|
339
|
-
const hydrate = (): void => {
|
|
340
|
-
currentSnapshot = store.load(config.scopeToken);
|
|
341
|
-
if (currentSnapshot.lastSyncedAt !== undefined) {
|
|
342
|
-
hasSynced = true;
|
|
343
|
-
}
|
|
344
|
-
if (!store.isDurable()) {
|
|
345
|
-
emit({
|
|
346
|
-
type: 'config_warning',
|
|
347
|
-
detail: 'Local storage unavailable; entitlements will not survive restart',
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
recompute();
|
|
351
|
-
};
|
|
352
|
-
|
|
353
|
-
/** Translates store purchases into persisted records, dropping unknown products. */
|
|
354
|
-
const toPersisted = (purchases: ProviderPurchase[]): PersistedPurchase[] => {
|
|
355
|
-
const records: PersistedPurchase[] = [];
|
|
356
|
-
for (const purchase of purchases) {
|
|
357
|
-
const productKey = productKeyForStoreId(resolved, purchase.storeProductId);
|
|
358
|
-
if (!productKey) {
|
|
359
|
-
// Legitimately possible: a user owns something from a build whose
|
|
360
|
-
// catalog has since changed. Ignored rather than treated as impossible.
|
|
361
|
-
continue;
|
|
362
|
-
}
|
|
363
|
-
const entry = resolved.entries.get(productKey);
|
|
364
|
-
if (!entry) {
|
|
365
|
-
continue;
|
|
366
|
-
}
|
|
367
|
-
const existing = currentSnapshot.purchases.find(
|
|
368
|
-
p => p.transactionId === purchase.transactionId,
|
|
369
|
-
);
|
|
370
|
-
if (purchase.state === 'unspecified') {
|
|
371
|
-
// The store reported a state it could not name. That is no basis for
|
|
372
|
-
// granting, finalizing, or ending anything: a new transaction is left
|
|
373
|
-
// out until the store says what it is, and a known one keeps what we
|
|
374
|
-
// already knew. 0.1.0 recorded it as `purchased` — a grant, and an
|
|
375
|
-
// acknowledgement of a purchase nobody confirmed.
|
|
376
|
-
if (existing) {
|
|
377
|
-
records.push(existing);
|
|
378
|
-
}
|
|
379
|
-
continue;
|
|
380
|
-
}
|
|
381
|
-
records.push({
|
|
382
|
-
transactionId: purchase.transactionId,
|
|
383
|
-
productKey,
|
|
384
|
-
productType: entry.productType,
|
|
385
|
-
token: purchase.token,
|
|
386
|
-
state: purchase.state === 'pending' ? 'pending' : 'purchased',
|
|
387
|
-
purchasedAt: purchase.purchasedAt,
|
|
388
|
-
// Preserve finalize progress across syncs: a sync must never reset a
|
|
389
|
-
// queued acknowledgement and lose the paid transaction.
|
|
390
|
-
finalizeState: resolveFinalizeState(purchase, existing),
|
|
391
|
-
attempts: existing?.attempts ?? 0,
|
|
392
|
-
validationState: existing?.validationState ?? 'not_required',
|
|
393
|
-
expiresAt: purchase.expiresAt,
|
|
394
|
-
willAutoRenew: purchase.willAutoRenew,
|
|
395
|
-
});
|
|
396
|
-
}
|
|
397
|
-
return records;
|
|
398
|
-
};
|
|
399
|
-
|
|
400
|
-
const scheduleReconnect = (): void => {
|
|
401
|
-
if (disposed) {
|
|
402
|
-
return;
|
|
403
|
-
}
|
|
404
|
-
if (reconnectAttempts >= resolved.retry.reconnect.maxAttempts) {
|
|
405
|
-
// Out of budget for now, not for good. Reported as a retryable failure
|
|
406
|
-
// rather than left at `connecting`, which promised a reconnect nobody
|
|
407
|
-
// was going to make; `handleForeground` starts a fresh budget.
|
|
408
|
-
setStatus({ kind: 'failed', retryable: true, reconnectAttempts });
|
|
409
|
-
return;
|
|
410
|
-
}
|
|
411
|
-
const attempt = reconnectAttempts + 1;
|
|
412
|
-
const delay = backoffDelayMs(attempt, resolved.retry.reconnect);
|
|
413
|
-
reconnectTimer = setTimeout(() => {
|
|
414
|
-
reconnectTimer = undefined;
|
|
415
|
-
if (disposed) {
|
|
416
|
-
return;
|
|
417
|
-
}
|
|
418
|
-
reconnectAttempts = attempt;
|
|
419
|
-
setStatus({ kind: 'connecting', reconnectAttempts });
|
|
420
|
-
reconnectOnce().catch(() => undefined);
|
|
421
|
-
}, delay);
|
|
422
|
-
};
|
|
423
|
-
|
|
424
|
-
/**
|
|
425
|
-
* One reconnect attempt: a full teardown-then-connect cycle, deliberately.
|
|
426
|
-
*
|
|
427
|
-
* The upstream binding's own `reconnect()` has reported success without
|
|
428
|
-
* rebuilding anything (openiap#408), so we never trust a shortcut here.
|
|
429
|
-
*/
|
|
430
|
-
const reconnectOnce = async (): Promise<void> => {
|
|
431
|
-
try {
|
|
432
|
-
await provider.disconnect();
|
|
433
|
-
} catch {
|
|
434
|
-
/* already down; nothing to tear down */
|
|
435
|
-
}
|
|
436
|
-
const outcome = await provider.connect();
|
|
437
|
-
if (disposed) {
|
|
438
|
-
return;
|
|
439
|
-
}
|
|
440
|
-
if (outcome.ok) {
|
|
441
|
-
reconnectAttempts = 0;
|
|
442
|
-
setStatus({ kind: 'ready', reconnectAttempts: 0 });
|
|
443
|
-
} else {
|
|
444
|
-
scheduleReconnect();
|
|
445
|
-
}
|
|
173
|
+
emit({ type: 'environment_blocked', productKey, environment: config.environment });
|
|
446
174
|
};
|
|
447
175
|
|
|
448
176
|
const finalizer = createFinalizer({
|
|
449
177
|
provider,
|
|
450
178
|
retry: resolved.retry,
|
|
451
179
|
attemptTimeoutMs: resolved.timeouts.finalizeAttemptMs,
|
|
452
|
-
snapshot: () =>
|
|
180
|
+
snapshot: () => state.snapshot,
|
|
453
181
|
commit,
|
|
454
182
|
emit,
|
|
455
183
|
now,
|
|
456
|
-
disposed: () => disposed,
|
|
184
|
+
disposed: () => state.disposed,
|
|
457
185
|
});
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
186
|
+
const products = createProducts({ resolved, provider, emit, now });
|
|
187
|
+
// Every sync also settles what an earlier session left owed, and re-asks the
|
|
188
|
+
// backend about grants it deferred (FR-039, FR-042, FR-072a).
|
|
189
|
+
const syncer = createSync(state, async () => {
|
|
190
|
+
await finalizer.drain();
|
|
191
|
+
await purchaseFlow.revalidate();
|
|
464
192
|
});
|
|
465
|
-
|
|
466
193
|
const purchaseFlow = createPurchaseFlow({
|
|
467
194
|
config,
|
|
468
195
|
resolved,
|
|
469
196
|
provider,
|
|
470
197
|
platform,
|
|
471
198
|
finalizer,
|
|
472
|
-
snapshot: () =>
|
|
199
|
+
snapshot: () => state.snapshot,
|
|
473
200
|
commit,
|
|
474
201
|
emit,
|
|
475
202
|
now,
|
|
476
|
-
nextCorrelationId
|
|
477
|
-
statusKind: () => status.kind,
|
|
203
|
+
nextCorrelationId,
|
|
204
|
+
statusKind: () => state.status.kind,
|
|
478
205
|
mayCreateRealTransaction: () => mayCreateRealTransaction(config),
|
|
479
|
-
recordBlockedOperation
|
|
480
|
-
// `already_owned`
|
|
481
|
-
//
|
|
482
|
-
//
|
|
206
|
+
recordBlockedOperation,
|
|
207
|
+
// `already_owned` re-syncs rather than surfacing an error, which is how a
|
|
208
|
+
// reinstall repairs itself (FR-033); the entitlement arrives via the change
|
|
209
|
+
// signal.
|
|
483
210
|
requestSync: trigger => {
|
|
484
|
-
|
|
211
|
+
syncer.sync(trigger).catch(() => undefined);
|
|
485
212
|
},
|
|
486
213
|
runExclusive: operation => purchaseLock.run(operation),
|
|
487
214
|
});
|
|
488
|
-
|
|
489
215
|
const subscriptions = createSubscriptions({
|
|
490
216
|
resolved,
|
|
491
217
|
provider,
|
|
492
|
-
snapshot: () =>
|
|
218
|
+
snapshot: () => state.snapshot,
|
|
493
219
|
purchase: (productKey, options) => purchaseFlow.purchase(productKey, options),
|
|
494
220
|
});
|
|
221
|
+
const connection = createConnection(state, purchase => {
|
|
222
|
+
purchaseFlow.ingest(purchase).catch(() => undefined);
|
|
223
|
+
});
|
|
495
224
|
|
|
496
|
-
/**
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
* purchase is open.
|
|
502
|
-
*/
|
|
503
|
-
const watchPurchases = (): void => {
|
|
504
|
-
// Replaced, never stacked: a second subscription would ingest every update twice.
|
|
505
|
-
purchaseSub?.();
|
|
506
|
-
purchaseSub = provider.observePurchases(purchase => {
|
|
507
|
-
if (disposed) {
|
|
508
|
-
return;
|
|
509
|
-
}
|
|
510
|
-
purchaseFlow.ingest(purchase).catch(() => undefined);
|
|
511
|
-
});
|
|
512
|
-
};
|
|
513
|
-
|
|
514
|
-
const watchConnection = (): void => {
|
|
515
|
-
connectionSub?.();
|
|
516
|
-
connectionSub = provider.observeConnection((connected, reason) => {
|
|
517
|
-
if (disposed) {
|
|
518
|
-
return;
|
|
519
|
-
}
|
|
520
|
-
emit({
|
|
521
|
-
type: 'connection_changed',
|
|
522
|
-
connected,
|
|
523
|
-
reason,
|
|
524
|
-
attempt: reconnectAttempts,
|
|
525
|
-
});
|
|
526
|
-
if (!connected && status.kind === 'ready') {
|
|
527
|
-
setStatus({ kind: 'connecting' });
|
|
528
|
-
scheduleReconnect();
|
|
529
|
-
}
|
|
530
|
-
});
|
|
531
|
-
};
|
|
225
|
+
/** A subscription that is a no-op once the engine is cleaned up. */
|
|
226
|
+
function subscriber<T>(listeners: { add(listener: (value: T) => void): Unsubscribe }) {
|
|
227
|
+
return (listener: (value: T) => void): Unsubscribe =>
|
|
228
|
+
state.disposed ? () => undefined : listeners.add(listener);
|
|
229
|
+
}
|
|
532
230
|
|
|
533
231
|
const engine: PaymentEngine = {
|
|
534
232
|
async initialize(): Promise<InitOutcome> {
|
|
535
233
|
if (!resolved.enabled) {
|
|
536
|
-
// Selling is off, but entitlements already held stay readable
|
|
537
|
-
//
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
// no sync will ever run to mark the snapshot trustworthy.
|
|
541
|
-
setStatus({ kind: 'disabled' });
|
|
542
|
-
hydrate();
|
|
234
|
+
// Selling is off, but entitlements already held stay readable (FR-012):
|
|
235
|
+
// hydration runs here too, or they would read `unknown` forever.
|
|
236
|
+
state.setStatus({ kind: 'disabled' });
|
|
237
|
+
state.hydrate();
|
|
543
238
|
return {
|
|
544
239
|
ok: false,
|
|
545
240
|
error: new PaymentError({
|
|
@@ -549,276 +244,74 @@ export function createEngine(config: PaymentConfig, deps: EngineDeps): PaymentEn
|
|
|
549
244
|
}),
|
|
550
245
|
};
|
|
551
246
|
}
|
|
552
|
-
|
|
553
|
-
//
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
247
|
+
// Checked before the shortcut below: a cleaned-up engine that had been
|
|
248
|
+
// ready used to answer `ok` while nothing behind it worked any more.
|
|
249
|
+
if (state.disposed) {
|
|
250
|
+
return {
|
|
251
|
+
ok: false,
|
|
252
|
+
error: new PaymentError({
|
|
253
|
+
code: 'PAYMENT_NOT_CONFIGURED',
|
|
254
|
+
message: 'Engine has been cleaned up',
|
|
255
|
+
operation: 'initialize',
|
|
256
|
+
}),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
// Already up: answered without reconnecting, or a later sequential call
|
|
260
|
+
// would leave a second set of native listeners behind.
|
|
261
|
+
if (state.status.kind === 'ready') {
|
|
557
262
|
return { ok: true };
|
|
558
263
|
}
|
|
559
|
-
|
|
560
264
|
return initFlight.run(async () => {
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
}),
|
|
569
|
-
};
|
|
265
|
+
state.setStatus({ kind: 'connecting' });
|
|
266
|
+
// Before connecting, so a warm start answers from the last good sync
|
|
267
|
+
// while the network is still in flight (FR-053).
|
|
268
|
+
state.hydrate();
|
|
269
|
+
const connected = await connection.connect();
|
|
270
|
+
if (!connected.ok) {
|
|
271
|
+
return connected;
|
|
570
272
|
}
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
//
|
|
575
|
-
// questions from the last good sync while the network is still in
|
|
576
|
-
// flight (FR-053).
|
|
577
|
-
hydrate();
|
|
578
|
-
|
|
579
|
-
const outcome = await withTimeout(
|
|
580
|
-
provider.connect(),
|
|
581
|
-
resolved.timeouts.connectMs,
|
|
582
|
-
() =>
|
|
583
|
-
new PaymentError({
|
|
584
|
-
code: 'PAYMENT_TIMEOUT',
|
|
585
|
-
message: 'Connecting to the billing service timed out',
|
|
586
|
-
operation: 'initialize',
|
|
587
|
-
retryable: true,
|
|
588
|
-
}),
|
|
589
|
-
).catch((error: PaymentError) => ({
|
|
590
|
-
ok: false as const,
|
|
591
|
-
reason: 'store_error' as const,
|
|
592
|
-
retryable: error.retryable,
|
|
593
|
-
}));
|
|
594
|
-
|
|
595
|
-
if (!outcome.ok) {
|
|
596
|
-
if (outcome.reason === 'store_error') {
|
|
597
|
-
setStatus({ kind: 'failed', retryable: outcome.retryable });
|
|
598
|
-
return {
|
|
599
|
-
ok: false as const,
|
|
600
|
-
error: new PaymentError({
|
|
601
|
-
code: 'PAYMENT_STORE_ERROR',
|
|
602
|
-
message: 'Could not connect to the billing service',
|
|
603
|
-
operation: 'initialize',
|
|
604
|
-
retryable: outcome.retryable,
|
|
605
|
-
}),
|
|
606
|
-
};
|
|
607
|
-
}
|
|
608
|
-
// No billing service, an outdated one, or an unsupported platform:
|
|
609
|
-
// each distinguishable, and none of them may break the host app
|
|
610
|
-
// (FR-021, FR-023).
|
|
611
|
-
setStatus({
|
|
612
|
-
kind: 'unavailable',
|
|
613
|
-
unavailableReason: outcome.reason,
|
|
614
|
-
retryable: outcome.retryable,
|
|
615
|
-
});
|
|
616
|
-
return {
|
|
617
|
-
ok: false as const,
|
|
618
|
-
error: new PaymentError({
|
|
619
|
-
code:
|
|
620
|
-
outcome.reason === 'platform_unsupported'
|
|
621
|
-
? 'PAYMENT_UNSUPPORTED_PLATFORM'
|
|
622
|
-
: 'PAYMENT_UNAVAILABLE',
|
|
623
|
-
message: `Billing unavailable: ${outcome.reason}`,
|
|
624
|
-
operation: 'initialize',
|
|
625
|
-
retryable: outcome.retryable,
|
|
626
|
-
}),
|
|
627
|
-
};
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
watchConnection();
|
|
631
|
-
watchPurchases();
|
|
632
|
-
everConnected = true;
|
|
633
|
-
setStatus({ kind: 'ready', reconnectAttempts: 0 });
|
|
634
|
-
await engine.sync('initialize');
|
|
635
|
-
// Drain after syncing, so a transaction the store still reports as owned
|
|
636
|
-
// has been re-queued first. This is the step that catches a purchase
|
|
637
|
-
// the previous session paid for but was killed before acknowledging
|
|
638
|
-
// (FR-042).
|
|
273
|
+
await syncer.sync('initialize');
|
|
274
|
+
// After syncing, so a transaction the store still reports owned has been
|
|
275
|
+
// re-queued first: this catches a purchase the previous session paid for
|
|
276
|
+
// but was killed before acknowledging (FR-042).
|
|
639
277
|
await finalizer.drain();
|
|
640
278
|
return { ok: true as const };
|
|
641
279
|
});
|
|
642
280
|
},
|
|
643
281
|
|
|
644
282
|
cleanup(): void {
|
|
645
|
-
disposed = true;
|
|
646
|
-
|
|
647
|
-
connectionSub = undefined;
|
|
648
|
-
purchaseSub?.();
|
|
649
|
-
purchaseSub = undefined;
|
|
283
|
+
state.disposed = true;
|
|
284
|
+
connection.dispose();
|
|
650
285
|
finalizer.dispose();
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
}
|
|
655
|
-
statusListeners.clear();
|
|
656
|
-
entitlementListeners.clear();
|
|
657
|
-
pendingListeners.clear();
|
|
286
|
+
state.statusListeners.clear();
|
|
287
|
+
state.entitlementListeners.clear();
|
|
288
|
+
state.pendingListeners.clear();
|
|
658
289
|
// Disarmed last: after this nothing is emitted, and a late store callback
|
|
659
290
|
// is dropped safely (FR-082).
|
|
660
|
-
emitter.dispose();
|
|
291
|
+
state.emitter.dispose();
|
|
661
292
|
provider.disconnect().catch(() => undefined);
|
|
662
293
|
},
|
|
663
294
|
|
|
664
|
-
getStatus: () => status,
|
|
665
|
-
|
|
666
|
-
subscribeStatus(listener): Unsubscribe {
|
|
667
|
-
if (disposed) {
|
|
668
|
-
return () => undefined;
|
|
669
|
-
}
|
|
670
|
-
return statusListeners.add(listener);
|
|
671
|
-
},
|
|
295
|
+
getStatus: () => state.status,
|
|
296
|
+
subscribeStatus: subscriber<PaymentStatus>(state.statusListeners),
|
|
672
297
|
|
|
673
298
|
/** Synchronous, in-memory, zero I/O — it is read on render paths (PERF-001). */
|
|
674
299
|
getEntitlement(key): EntitlementState {
|
|
675
|
-
return
|
|
676
|
-
entitlements[key] ?? {
|
|
677
|
-
key,
|
|
678
|
-
status: 'unknown',
|
|
679
|
-
sources: [],
|
|
680
|
-
isStale: false,
|
|
681
|
-
}
|
|
682
|
-
);
|
|
683
|
-
},
|
|
684
|
-
|
|
685
|
-
getAllEntitlements: () => entitlements,
|
|
686
|
-
|
|
687
|
-
getPendingPurchases: () => pending,
|
|
688
|
-
|
|
689
|
-
subscribePendingPurchases(listener): Unsubscribe {
|
|
690
|
-
if (disposed) {
|
|
691
|
-
return () => undefined;
|
|
692
|
-
}
|
|
693
|
-
return pendingListeners.add(listener);
|
|
300
|
+
return state.entitlements[key] ?? { key, status: 'unknown', sources: [], isStale: false };
|
|
694
301
|
},
|
|
302
|
+
getAllEntitlements: () => state.entitlements,
|
|
303
|
+
subscribeEntitlements: subscriber<EntitlementMap>(state.entitlementListeners),
|
|
304
|
+
getPendingPurchases: () => state.pending,
|
|
305
|
+
subscribePendingPurchases: subscriber<PendingPurchasesState>(state.pendingListeners),
|
|
695
306
|
|
|
696
|
-
|
|
697
|
-
if (disposed) {
|
|
698
|
-
return () => undefined;
|
|
699
|
-
}
|
|
700
|
-
return entitlementListeners.add(listener);
|
|
701
|
-
},
|
|
702
|
-
|
|
703
|
-
async sync(trigger: SyncTrigger = 'manual'): Promise<SyncOutcome> {
|
|
704
|
-
if (disposed || !resolved.enabled) {
|
|
705
|
-
return {
|
|
706
|
-
ok: false,
|
|
707
|
-
error: new PaymentError({
|
|
708
|
-
code: 'PAYMENT_DISABLED',
|
|
709
|
-
message: 'Payments are disabled',
|
|
710
|
-
operation: 'sync',
|
|
711
|
-
}),
|
|
712
|
-
};
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
if (trigger === 'foreground' && !foregroundLimiter.tryAcquire()) {
|
|
716
|
-
// Rejected rather than deferred: the next foreground event will try
|
|
717
|
-
// again, whereas queueing would fire a burst once the interval elapsed.
|
|
718
|
-
return { ok: true, restoredCount: 0, revokedCount: 0 };
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
// Concurrent callers join one flight, so repeated syncs make exactly one
|
|
722
|
-
// store request (FR-063).
|
|
723
|
-
const outcome = await syncFlight.run(async () => {
|
|
724
|
-
try {
|
|
725
|
-
const owned = await withTimeout(
|
|
726
|
-
provider.queryOwned(),
|
|
727
|
-
resolved.timeouts.syncMs,
|
|
728
|
-
() =>
|
|
729
|
-
new PaymentError({
|
|
730
|
-
code: 'PAYMENT_TIMEOUT',
|
|
731
|
-
message: 'Syncing entitlements timed out',
|
|
732
|
-
operation: 'sync',
|
|
733
|
-
retryable: true,
|
|
734
|
-
}),
|
|
735
|
-
);
|
|
736
|
-
|
|
737
|
-
const before = new Set(
|
|
738
|
-
currentSnapshot.purchases
|
|
739
|
-
.filter(p => p.state === 'purchased')
|
|
740
|
-
.map(p => p.transactionId),
|
|
741
|
-
);
|
|
742
|
-
const records = toPersisted(owned);
|
|
743
|
-
const after = new Set(
|
|
744
|
-
records.filter(p => p.state === 'purchased').map(p => p.transactionId),
|
|
745
|
-
);
|
|
746
|
-
|
|
747
|
-
// A transaction the store no longer reports is revoked — a refund.
|
|
748
|
-
// The cache must not resurrect it (FR-064, FR-055).
|
|
749
|
-
let revokedCount = 0;
|
|
750
|
-
for (const id of before) {
|
|
751
|
-
if (!after.has(id)) {
|
|
752
|
-
revokedCount += 1;
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
let restoredCount = 0;
|
|
756
|
-
for (const id of after) {
|
|
757
|
-
if (!before.has(id)) {
|
|
758
|
-
restoredCount += 1;
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
hasSynced = true;
|
|
763
|
-
commit({
|
|
764
|
-
...currentSnapshot,
|
|
765
|
-
purchases: records,
|
|
766
|
-
lastSyncedAt: now(),
|
|
767
|
-
});
|
|
768
|
-
|
|
769
|
-
emit({
|
|
770
|
-
type: 'sync_completed',
|
|
771
|
-
restoredCount,
|
|
772
|
-
revokedCount,
|
|
773
|
-
trigger,
|
|
774
|
-
});
|
|
775
|
-
|
|
776
|
-
return { ok: true as const, restoredCount, revokedCount };
|
|
777
|
-
} catch (error) {
|
|
778
|
-
const paymentError =
|
|
779
|
-
error instanceof PaymentError
|
|
780
|
-
? error
|
|
781
|
-
: new PaymentError({
|
|
782
|
-
code: 'PAYMENT_STORE_ERROR',
|
|
783
|
-
message: 'Sync failed',
|
|
784
|
-
operation: 'sync',
|
|
785
|
-
retryable: true,
|
|
786
|
-
});
|
|
787
|
-
// A failed sync leaves the previous snapshot in place, so offline
|
|
788
|
-
// entitlements survive rather than being wiped (FR-053).
|
|
789
|
-
emit({
|
|
790
|
-
type: 'internal_error',
|
|
791
|
-
code: paymentError.code,
|
|
792
|
-
operation: 'sync',
|
|
793
|
-
});
|
|
794
|
-
return { ok: false as const, error: paymentError };
|
|
795
|
-
}
|
|
796
|
-
});
|
|
797
|
-
|
|
798
|
-
if (outcome.ok) {
|
|
799
|
-
// Every sync is also a chance to settle what a previous session left
|
|
800
|
-
// owed — a pending purchase that completed while the app was closed, a
|
|
801
|
-
// transaction whose acknowledgement failed last time (FR-039, FR-042) —
|
|
802
|
-
// and a backend decision we had to defer, on the app's own rhythm
|
|
803
|
-
// rather than a timer of ours (FR-072a).
|
|
804
|
-
//
|
|
805
|
-
// Outside the sync flight on purpose. Inside it, one store call or hook
|
|
806
|
-
// that never answered held the flight, and every later sync joined that
|
|
807
|
-
// same stuck promise for the rest of the session.
|
|
808
|
-
await settleFlight.run(async () => {
|
|
809
|
-
await finalizer.drain();
|
|
810
|
-
await purchaseFlow.revalidate();
|
|
811
|
-
});
|
|
812
|
-
}
|
|
813
|
-
return outcome;
|
|
814
|
-
},
|
|
307
|
+
sync: trigger => syncer.sync(trigger),
|
|
815
308
|
|
|
816
309
|
async restore(): Promise<RestoreOutcome> {
|
|
817
|
-
const outcome = await
|
|
310
|
+
const outcome = await syncer.sync('manual');
|
|
818
311
|
if (!outcome.ok) {
|
|
819
312
|
return { ok: false, error: outcome.error };
|
|
820
313
|
}
|
|
821
|
-
const held = Object.values(entitlements)
|
|
314
|
+
const held = Object.values(state.entitlements)
|
|
822
315
|
.filter(e => e.status === 'entitled')
|
|
823
316
|
.map(e => e.key);
|
|
824
317
|
// "Nothing found" is a distinct success, so an app never claims to have
|
|
@@ -828,94 +321,41 @@ export function createEngine(config: PaymentConfig, deps: EngineDeps): PaymentEn
|
|
|
828
321
|
: { ok: true, restored: false };
|
|
829
322
|
},
|
|
830
323
|
|
|
831
|
-
getLastSyncedAt: () =>
|
|
324
|
+
getLastSyncedAt: () => state.snapshot.lastSyncedAt,
|
|
832
325
|
|
|
833
326
|
async clearLocalData(): Promise<void> {
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
// Back to `unknown`, not `not_entitled`: after a logout the package has no
|
|
838
|
-
// basis for either claim until the next sync.
|
|
839
|
-
entitlements = initialEntitlements(resolved);
|
|
840
|
-
entitlementListeners.notify(entitlements);
|
|
841
|
-
// Same reasoning, and no `purchase_pending_resolved`: the records were
|
|
842
|
-
// discarded locally, not ended at the store.
|
|
843
|
-
publishPending(UNKNOWN_PENDING);
|
|
844
|
-
},
|
|
845
|
-
|
|
846
|
-
subscribeEvents: listener => emitter.subscribe(listener),
|
|
847
|
-
resolvedConfig: () => resolved,
|
|
848
|
-
nextCorrelationId: () => createCorrelationId(correlationCounter),
|
|
849
|
-
emitter: () => emitter,
|
|
850
|
-
snapshot: () => currentSnapshot,
|
|
851
|
-
commitSnapshot: commit,
|
|
852
|
-
mayCreateRealTransaction: () => mayCreateRealTransaction(config),
|
|
853
|
-
|
|
854
|
-
purchase(productKey, options) {
|
|
855
|
-
return purchaseFlow.purchase(productKey, options);
|
|
856
|
-
},
|
|
857
|
-
|
|
858
|
-
consume(ref) {
|
|
859
|
-
return finalizer.consume(ref);
|
|
860
|
-
},
|
|
861
|
-
|
|
862
|
-
getUnfinalized() {
|
|
863
|
-
return finalizer.unfinalized();
|
|
327
|
+
state.forget();
|
|
328
|
+
// Prices are per account and storefront; a logout drops them too (PAY-12).
|
|
329
|
+
products.invalidate();
|
|
864
330
|
},
|
|
865
331
|
|
|
866
|
-
|
|
867
|
-
return products.get(productKeys);
|
|
868
|
-
},
|
|
332
|
+
subscribeEvents: listener => state.emitter.subscribe(listener),
|
|
869
333
|
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
openSubscriptionManagement(productKey) {
|
|
879
|
-
return subscriptions.openManagement(productKey);
|
|
880
|
-
},
|
|
881
|
-
|
|
882
|
-
confirmPriceChange(productKey) {
|
|
883
|
-
return subscriptions.confirmPriceChange(productKey);
|
|
884
|
-
},
|
|
334
|
+
purchase: (productKey, options) => purchaseFlow.purchase(productKey, options),
|
|
335
|
+
consume: ref => finalizer.consume(ref),
|
|
336
|
+
getUnfinalized: () => finalizer.unfinalized(),
|
|
337
|
+
getProducts: productKeys => products.get(productKeys),
|
|
338
|
+
refreshProducts: productKeys => products.refresh(productKeys),
|
|
339
|
+
changePlan: (from, to, mode, options) => subscriptions.changePlan(from, to, mode, options),
|
|
340
|
+
openSubscriptionManagement: productKey => subscriptions.openManagement(productKey),
|
|
341
|
+
confirmPriceChange: productKey => subscriptions.confirmPriceChange(productKey),
|
|
885
342
|
|
|
886
343
|
async handleForeground(): Promise<void> {
|
|
887
|
-
|
|
888
|
-
//
|
|
889
|
-
//
|
|
890
|
-
|
|
891
|
-
reconnectAttempts = 0;
|
|
892
|
-
setStatus({ kind: 'connecting', reconnectAttempts: 0 });
|
|
893
|
-
await reconnectOnce().catch(() => undefined);
|
|
894
|
-
}
|
|
895
|
-
// Rate-limited inside sync, so an app that calls this on every incidental
|
|
896
|
-
// foreground transition still produces at most one request per interval.
|
|
897
|
-
await engine.sync('foreground');
|
|
898
|
-
},
|
|
899
|
-
|
|
900
|
-
/**
|
|
901
|
-
* Records a purchase refused because this environment may not move real
|
|
902
|
-
* money (FR-014). Written into status, not just logged, so a test can
|
|
903
|
-
* assert the block happened.
|
|
904
|
-
*/
|
|
905
|
-
recordBlockedOperation(productKey: ProductKey): void {
|
|
906
|
-
const blocked: BlockedOperation = {
|
|
907
|
-
productKey,
|
|
908
|
-
environment: config.environment,
|
|
909
|
-
at: now(),
|
|
910
|
-
};
|
|
911
|
-
setStatus({ blockedOperations: [...status.blockedOperations, blocked] });
|
|
912
|
-
emit({
|
|
913
|
-
type: 'environment_blocked',
|
|
914
|
-
productKey,
|
|
915
|
-
environment: config.environment,
|
|
916
|
-
});
|
|
344
|
+
await connection.recover();
|
|
345
|
+
// Rate-limited inside sync: at most one request per interval however often
|
|
346
|
+
// the app calls this.
|
|
347
|
+
await syncer.sync('foreground');
|
|
917
348
|
},
|
|
918
349
|
};
|
|
919
350
|
|
|
351
|
+
attachEngineInternals(engine, {
|
|
352
|
+
resolvedConfig: () => resolved,
|
|
353
|
+
nextCorrelationId,
|
|
354
|
+
emitter: () => state.emitter,
|
|
355
|
+
snapshot: () => state.snapshot,
|
|
356
|
+
commitSnapshot: commit,
|
|
357
|
+
mayCreateRealTransaction: () => mayCreateRealTransaction(config),
|
|
358
|
+
recordBlockedOperation,
|
|
359
|
+
});
|
|
920
360
|
return engine;
|
|
921
361
|
}
|