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