@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.
@@ -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 { createListenerSet, currentPlatform } from '@chipmobilesdk/rn-core';
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 { PaymentError, redact } from '../diagnostics/errors';
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 { createExclusive, createRateLimiter, createSingleFlight, withTimeout } from '../core/mutex';
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 { backoffDelayMs } from '../core/retry';
52
- import { createSnapshotStore, type SnapshotStore } from '../persistence/mmkv-store';
53
- import {
54
- emptySnapshot,
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): 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.
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 store = deps.snapshotStore ?? createSnapshotStore();
171
- // A listener that throws is a consumer bug, so it is routed to the app's own
172
- // logger rather than back into the event stream (FR-080, FR-081). Swallowing it
173
- // entirely would hide a broken analytics adapter indefinitely.
174
- const emitter = createEmitter((error, event) => {
175
- config.logger?.error?.('payment event listener threw', {
176
- eventType: event.type,
177
- reason: redact(error),
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
- const syncFlight = createSingleFlight<SyncOutcome>();
184
- /** Finalize drain and revalidation, joined the same way but outside the sync flight. */
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
- const emit = (event: PaymentEventInput) => {
223
- emitter.emit({ ...event, timestamp: event.timestamp ?? now() } as PaymentEvent);
224
- };
225
-
226
- const setStatus = (next: Partial<PaymentStatus>): void => {
227
- if (disposed) {
228
- return;
229
- }
230
- status = { ...status, ...next };
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
- statusListeners.notify(status);
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: () => currentSnapshot,
180
+ snapshot: () => state.snapshot,
453
181
  commit,
454
182
  emit,
455
183
  now,
456
- disposed: () => disposed,
184
+ disposed: () => state.disposed,
457
185
  });
458
-
459
- const products = createProducts({
460
- resolved,
461
- provider,
462
- emit,
463
- now,
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: () => currentSnapshot,
199
+ snapshot: () => state.snapshot,
473
200
  commit,
474
201
  emit,
475
202
  now,
476
- nextCorrelationId: () => createCorrelationId(correlationCounter),
477
- statusKind: () => status.kind,
203
+ nextCorrelationId,
204
+ statusKind: () => state.status.kind,
478
205
  mayCreateRealTransaction: () => mayCreateRealTransaction(config),
479
- recordBlockedOperation: productKey => engine.recordBlockedOperation(productKey),
480
- // `already_owned` triggers a re-sync rather than surfacing an error, which is
481
- // how a reinstall repairs itself (FR-033). Fire-and-forget: the caller gets
482
- // its typed result now and the entitlement arrives via the change signal.
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
- engine.sync(trigger).catch(() => undefined);
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: () => currentSnapshot,
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
- * Watches for store-pushed purchase updates.
498
- *
499
- * This is the channel a pending purchase settling between sessions arrives on
500
- * (FR-039), so it must stay subscribed for the whole session, not only while a
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 — turning
537
- // off sales is not the same as taking away what someone bought (FR-012).
538
- // Hydration runs here too: without it a previously granted entitlement
539
- // would read `unknown` forever in a build with selling disabled, because
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
- // Already up: answered without reconnecting. `initFlight` only joins
554
- // concurrent callers, so a later sequential call used to run the whole
555
- // connection again and leave a second set of native listeners behind.
556
- if (status.kind === 'ready') {
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
- if (disposed) {
562
- return {
563
- ok: false as const,
564
- error: new PaymentError({
565
- code: 'PAYMENT_NOT_CONFIGURED',
566
- message: 'Engine has been cleaned up',
567
- operation: 'initialize',
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
- setStatus({ kind: 'connecting' });
573
-
574
- // Hydrate before connecting so a warm start can answer entitlement
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
- connectionSub?.();
647
- connectionSub = undefined;
648
- purchaseSub?.();
649
- purchaseSub = undefined;
283
+ state.disposed = true;
284
+ connection.dispose();
650
285
  finalizer.dispose();
651
- if (reconnectTimer !== undefined) {
652
- clearTimeout(reconnectTimer);
653
- reconnectTimer = undefined;
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
- subscribeEntitlements(listener): Unsubscribe {
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 engine.sync('manual');
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: () => currentSnapshot.lastSyncedAt,
324
+ getLastSyncedAt: () => state.snapshot.lastSyncedAt,
832
325
 
833
326
  async clearLocalData(): Promise<void> {
834
- store.clearAll();
835
- currentSnapshot = emptySnapshot(config.scopeToken);
836
- hasSynced = false;
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
- getProducts(productKeys) {
867
- return products.get(productKeys);
868
- },
332
+ subscribeEvents: listener => state.emitter.subscribe(listener),
869
333
 
870
- refreshProducts(productKeys) {
871
- return products.refresh(productKeys);
872
- },
873
-
874
- changePlan(fromProductKey, toProductKey, mode, options) {
875
- return subscriptions.changePlan(fromProductKey, toProductKey, mode, options);
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
- // A foreground return is the natural moment to try again after the
888
- // reconnect budget ran out: the network the app lost while backgrounded
889
- // is usually back. A fresh budget, and one immediate attempt.
890
- if (everConnected && !disposed && status.kind === 'failed' && reconnectTimer === undefined) {
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
  }