@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.
@@ -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 { PaymentError, redact } from '../diagnostics/errors';
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 { 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';
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 { backoffDelayMs } from '../core/retry';
50
- import { createSnapshotStore, type SnapshotStore } from '../persistence/mmkv-store';
51
- import {
52
- emptySnapshot,
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
- const platform = deps.platform ?? 'android';
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 store = deps.snapshotStore ?? createSnapshotStore();
165
- // A listener that throws is a consumer bug, so it is routed to the app's own
166
- // logger rather than back into the event stream (FR-080, FR-081). Swallowing it
167
- // entirely would hide a broken analytics adapter indefinitely.
168
- const emitter = createEmitter((error, event) => {
169
- config.logger?.error?.('payment event listener threw', {
170
- eventType: event.type,
171
- reason: redact(error),
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
- const syncFlight = createSingleFlight<SyncOutcome>();
178
- /** Finalize drain and revalidation, joined the same way but outside the sync flight. */
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
- const emit = (event: PaymentEventInput) => {
211
- emitter.emit({ ...event, timestamp: event.timestamp ?? now() } as PaymentEvent);
212
- };
213
-
214
- const setStatus = (next: Partial<PaymentStatus>): void => {
215
- if (disposed) {
216
- return;
217
- }
218
- status = { ...status, ...next };
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: () => currentSnapshot,
180
+ snapshot: () => state.snapshot,
454
181
  commit,
455
182
  emit,
456
183
  now,
457
- disposed: () => disposed,
184
+ disposed: () => state.disposed,
458
185
  });
459
-
460
- const products = createProducts({
461
- resolved,
462
- provider,
463
- emit,
464
- 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();
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: () => currentSnapshot,
199
+ snapshot: () => state.snapshot,
474
200
  commit,
475
201
  emit,
476
202
  now,
477
- nextCorrelationId: () => createCorrelationId(correlationCounter),
478
- statusKind: () => status.kind,
203
+ nextCorrelationId,
204
+ statusKind: () => state.status.kind,
479
205
  mayCreateRealTransaction: () => mayCreateRealTransaction(config),
480
- recordBlockedOperation: productKey => engine.recordBlockedOperation(productKey),
481
- // `already_owned` triggers a re-sync rather than surfacing an error, which is
482
- // how a reinstall repairs itself (FR-033). Fire-and-forget: the caller gets
483
- // 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.
484
210
  requestSync: trigger => {
485
- engine.sync(trigger).catch(() => undefined);
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: () => currentSnapshot,
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
- * Watches for store-pushed purchase updates.
499
- *
500
- * This is the channel a pending purchase settling between sessions arrives on
501
- * (FR-039), so it must stay subscribed for the whole session, not only while a
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 — turning
538
- // off sales is not the same as taking away what someone bought (FR-012).
539
- // Hydration runs here too: without it a previously granted entitlement
540
- // would read `unknown` forever in a build with selling disabled, because
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
- // Already up: answered without reconnecting. `initFlight` only joins
555
- // concurrent callers, so a later sequential call used to run the whole
556
- // connection again and leave a second set of native listeners behind.
557
- 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') {
558
262
  return { ok: true };
559
263
  }
560
-
561
264
  return initFlight.run(async () => {
562
- if (disposed) {
563
- return {
564
- ok: false as const,
565
- error: new PaymentError({
566
- code: 'PAYMENT_NOT_CONFIGURED',
567
- message: 'Engine has been cleaned up',
568
- operation: 'initialize',
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
- setStatus({ kind: 'connecting' });
574
-
575
- // Hydrate before connecting so a warm start can answer entitlement
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
- connectionSub?.();
648
- connectionSub = undefined;
649
- purchaseSub?.();
650
- purchaseSub = undefined;
283
+ state.disposed = true;
284
+ connection.dispose();
651
285
  finalizer.dispose();
652
- if (reconnectTimer !== undefined) {
653
- clearTimeout(reconnectTimer);
654
- reconnectTimer = undefined;
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
- subscribeEntitlements(listener): Unsubscribe {
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 engine.sync('manual');
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: () => currentSnapshot.lastSyncedAt,
324
+ getLastSyncedAt: () => state.snapshot.lastSyncedAt,
857
325
 
858
326
  async clearLocalData(): Promise<void> {
859
- store.clearAll();
860
- currentSnapshot = emptySnapshot(config.scopeToken);
861
- hasSynced = false;
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
- consume(ref) {
890
- return finalizer.consume(ref);
891
- },
332
+ subscribeEvents: listener => state.emitter.subscribe(listener),
892
333
 
893
- getUnfinalized() {
894
- return finalizer.unfinalized();
895
- },
896
-
897
- getProducts(productKeys) {
898
- return products.get(productKeys);
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
- // A foreground return is the natural moment to try again after the
919
- // reconnect budget ran out: the network the app lost while backgrounded
920
- // is usually back. A fresh budget, and one immediate attempt.
921
- if (everConnected && !disposed && status.kind === 'failed' && reconnectTimer === undefined) {
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
  }