@chipmobilesdk/rn-payment 0.4.0 → 0.5.0

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