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