@chipmobilesdk/rn-payment 0.3.0 → 0.4.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.
@@ -6,19 +6,24 @@
6
6
  * and lets an iOS adapter be added without changing a consumer call (FR-002,
7
7
  * FR-007). ESLint enforces that boundary.
8
8
  *
9
- * ## Verification status
9
+ * ## Verified against react-native-iap 16.7.3
10
10
  *
11
- * The binding is **not yet installed** — the root `react-native-nitro-modules`
12
- * bump it requires is held pending approval (see specs/013-rn-payment-sdk/plan.md,
13
- * Post-Implementation Notes). So the call shapes below are written against
14
- * `react-native-iap` 16.x as documented, and **every one must be verified against
15
- * the installed package** before this adapter is trusted on a device. The module
16
- * is required lazily and the surface it needs is declared explicitly in
17
- * `IapModule`, so a mismatch surfaces here rather than deep in the engine.
11
+ * 0.3.0 was written before the binding was installed, from its documentation, and
12
+ * no purchase could complete on a device. Checked against the installed 16.7.3
13
+ * source for 0.3.1, two facts shape the purchase path:
18
14
  *
19
- * No test exercises this file: the whole suite runs against the fake store, and
20
- * the IAP test double throws on every call precisely so that reaching this code in
21
- * a test fails loudly instead of passing against a stub.
15
+ * - The request is keyed by store: `google` / `apple`. 0.3.0 sent `android`, which
16
+ * the binding rejects (`empty-sku-list`) before Play opens. The request type is
17
+ * now the binding's own `RequestPurchaseProps`, so a renamed key fails to
18
+ * typecheck here instead of failing on a user's device.
19
+ * - On Android `requestPurchase` resolves with an **empty list** once the flow
20
+ * ends, whatever happened. The outcome (bought, pending, cancelled, failed)
21
+ * arrives only through `purchaseUpdatedListener` / `purchaseErrorListener`, so
22
+ * `launchPurchase` waits for those events.
23
+ *
24
+ * The module is still required lazily, and `IapModule` still names exactly the
25
+ * surface used. `__tests__/iap-adapter.test.ts` drives this file through a fake
26
+ * binding that behaves as 16.7.3 does; the rest of the suite uses the fake store.
22
27
  *
23
28
  * ## Known upstream defect this guards against
24
29
  *
@@ -31,6 +36,11 @@
31
36
  * shortcut.
32
37
  */
33
38
 
39
+ import type {
40
+ RequestPurchaseProps,
41
+ SubscriptionReplacementModeAndroid,
42
+ } from 'react-native-iap';
43
+
34
44
  import type { ProductType, Unsubscribe } from '../config/types';
35
45
  import type {
36
46
  ProviderCapabilities,
@@ -61,7 +71,8 @@ interface IapModule {
61
71
  type: 'in-app' | 'subs';
62
72
  }): Promise<RawProduct[]>;
63
73
  getAvailablePurchases(): Promise<RawPurchase[]>;
64
- requestPurchase(params: unknown): Promise<unknown>;
74
+ /** Typed by the binding itself, so a renamed request key fails to compile. */
75
+ requestPurchase(params: RequestPurchaseProps): Promise<unknown>;
65
76
  finishTransaction(params: {
66
77
  purchase: unknown;
67
78
  isConsumable?: boolean;
@@ -69,9 +80,9 @@ interface IapModule {
69
80
  purchaseUpdatedListener(
70
81
  listener: (purchase: RawPurchase) => void,
71
82
  ): { remove(): void };
72
- purchaseErrorListener(
73
- listener: (error: { code?: string; message?: string }) => void,
74
- ): { remove(): void };
83
+ purchaseErrorListener(listener: (error: RawPurchaseError) => void): {
84
+ remove(): void;
85
+ };
75
86
  deepLinkToSubscriptions?(params: { skuAndroid?: string }): Promise<void>;
76
87
  }
77
88
 
@@ -129,6 +140,75 @@ interface RawPurchase {
129
140
  dataAndroid?: string;
130
141
  }
131
142
 
143
+ /** What `purchaseErrorListener` delivers. */
144
+ interface RawPurchaseError {
145
+ code?: string;
146
+ message?: string;
147
+ productId?: string | null;
148
+ }
149
+
150
+ /**
151
+ * How long to wait for the outcome event after `requestPurchase` has resolved.
152
+ * The binding emits the event before or as it resolves, so this only bounds the
153
+ * case where none ever comes; it never runs while the user is in Play's UI.
154
+ */
155
+ const RESULT_EVENT_GRACE_MS = 2_000;
156
+
157
+ /** Play's replacement modes, by the codes the engine passes (`engine/subscriptions.ts`). */
158
+ const REPLACEMENT_MODES: Record<string, SubscriptionReplacementModeAndroid> = {
159
+ WITH_TIME_PRORATION: 'with-time-proration',
160
+ CHARGE_FULL_PRICE: 'charge-full-price',
161
+ CHARGE_PRORATED_PRICE: 'charge-prorated-price',
162
+ DEFERRED: 'deferred',
163
+ };
164
+
165
+ /** The request in the binding's 16.7 shape: keyed `google`, not `android`. */
166
+ export function toRequestProps(request: ProviderPurchaseRequest): RequestPurchaseProps {
167
+ const google = {
168
+ skus: [request.storeProductId],
169
+ ...(request.obfuscatedAccountId
170
+ ? { obfuscatedAccountId: request.obfuscatedAccountId }
171
+ : {}),
172
+ };
173
+ if (request.productType !== 'subscription') {
174
+ return { request: { google }, type: 'in-app' };
175
+ }
176
+
177
+ const replacementMode = request.prorationMode
178
+ ? REPLACEMENT_MODES[request.prorationMode]
179
+ : undefined;
180
+ return {
181
+ request: {
182
+ google: {
183
+ ...google,
184
+ ...(request.offerKey
185
+ ? {
186
+ subscriptionOffers: [
187
+ { sku: request.storeProductId, offerToken: request.offerKey },
188
+ ],
189
+ }
190
+ : {}),
191
+ // A plan change: the token of the purchase being replaced, and — for
192
+ // the replacement mode to apply — its product id (Billing 8.1+).
193
+ ...(request.replacePurchaseToken
194
+ ? { purchaseToken: request.replacePurchaseToken }
195
+ : {}),
196
+ ...(request.replacePurchaseToken &&
197
+ request.replaceStoreProductId &&
198
+ replacementMode
199
+ ? {
200
+ subscriptionProductReplacementParams: {
201
+ oldProductId: request.replaceStoreProductId,
202
+ replacementMode,
203
+ },
204
+ }
205
+ : {}),
206
+ },
207
+ },
208
+ type: 'subs',
209
+ };
210
+ }
211
+
132
212
  /** Play's `PurchaseState`: 0 unspecified, 1 purchased, 2 pending. */
133
213
  const PLAY_STATE_PURCHASED = 1;
134
214
  const PLAY_STATE_PENDING = 2;
@@ -272,10 +352,12 @@ function mapErrorCode(code: string | undefined): ProviderFailureReason {
272
352
  case 'user-cancelled':
273
353
  return 'user_cancelled';
274
354
  case 'E_ALREADY_OWNED':
355
+ case 'already-owned':
275
356
  case 'item-already-owned':
276
357
  return 'already_owned';
277
358
  case 'E_ITEM_UNAVAILABLE':
278
359
  case 'item-unavailable':
360
+ case 'sku-not-found':
279
361
  return 'unknown_product';
280
362
  case 'E_NOT_PREPARED':
281
363
  case 'not-prepared':
@@ -309,6 +391,8 @@ const ANDROID_CAPABILITIES: ProviderCapabilities = {
309
391
  supportsExternalTransactions: false,
310
392
  /** This one really does move money, which is what arms the environment guard. */
311
393
  createsRealTransactions: true,
394
+ /** The outcome event comes only when the user leaves Play's purchase UI. */
395
+ purchaseOutcomeIncludesStoreTime: true,
312
396
  };
313
397
 
314
398
  export interface IapAdapterOptions {
@@ -326,6 +410,8 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
326
410
  (connected: boolean, reason?: string) => void
327
411
  >();
328
412
  const purchaseListeners = new Set<(purchase: ProviderPurchase) => void>();
413
+ /** Only an in-flight `launchPurchase` listens here; see there. */
414
+ const purchaseErrorListeners = new Set<(error: RawPurchaseError) => void>();
329
415
  let updateSub: { remove(): void } | undefined;
330
416
  let errorSub: { remove(): void } | undefined;
331
417
 
@@ -347,6 +433,10 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
347
433
  };
348
434
 
349
435
  const attachListeners = (): void => {
436
+ // Every connect attaches, so whatever an earlier connect left must go first.
437
+ // Overwriting the handles orphaned the old native listeners, which kept
438
+ // firing — every purchase update reached the engine once per connect.
439
+ detachListeners();
350
440
  const mod = module();
351
441
  updateSub = mod.purchaseUpdatedListener(raw => {
352
442
  const purchase = normalizePurchase(raw);
@@ -361,6 +451,9 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
361
451
  if (mapErrorCode(error.code) === 'not_ready') {
362
452
  notifyConnection(false, 'not_prepared');
363
453
  }
454
+ for (const listener of purchaseErrorListeners) {
455
+ listener(error);
456
+ }
364
457
  });
365
458
  };
366
459
 
@@ -450,49 +543,69 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
450
543
  return raw.map(normalizePurchase);
451
544
  },
452
545
 
453
- async launchPurchase(
454
- request: ProviderPurchaseRequest,
455
- ): Promise<ProviderPurchaseOutcome> {
456
- try {
457
- const isSubscription = request.productType === 'subscription';
458
- const result = await module().requestPurchase({
459
- request: {
460
- android: {
461
- skus: [request.storeProductId],
462
- obfuscatedAccountIdAndroid: request.obfuscatedAccountId,
463
- ...(isSubscription && request.offerKey
464
- ? {
465
- subscriptionOffers: [
466
- {
467
- sku: request.storeProductId,
468
- offerToken: request.offerKey,
469
- },
470
- ],
471
- }
472
- : {}),
473
- ...(request.replaceStoreProductId
474
- ? {
475
- purchaseTokenAndroid: request.replaceStoreProductId,
476
- replacementModeAndroid: request.prorationMode,
477
- }
478
- : {}),
479
- },
480
- },
481
- type: isSubscription ? 'subs' : 'in-app',
482
- });
483
-
484
- // The binding may resolve with the purchase, or resolve empty and deliver
485
- // it through the update listener. Both are handled: the engine also
486
- // ingests listener-delivered purchases, so neither path loses one.
487
- const raw = Array.isArray(result) ? result[0] : result;
488
- if (!raw) {
489
- return { ok: false, reason: 'store_error', detail: 'no purchase returned' };
546
+ /**
547
+ * Settles on the first outcome event for this product: a purchase update
548
+ * (bought or pending) or a purchase error (cancelled, already owned, …).
549
+ *
550
+ * The binding's promise is not the outcome: on Android it resolves with an
551
+ * empty list when the flow ends, whatever happened. Its listeners also feed
552
+ * `observePurchases`, so the engine sees a bought purchase twice — here and
553
+ * as a store update — and grants it once (FR-044).
554
+ */
555
+ launchPurchase(request: ProviderPurchaseRequest): Promise<ProviderPurchaseOutcome> {
556
+ const sku = request.storeProductId;
557
+ return new Promise<ProviderPurchaseOutcome>(resolve => {
558
+ let settled = false;
559
+ let grace: ReturnType<typeof setTimeout> | undefined;
560
+
561
+ const onPurchase = (purchase: ProviderPurchase): void => {
562
+ if (purchase.storeProductId === sku) {
563
+ finish({ ok: true, purchase });
564
+ }
565
+ };
566
+ const onError = (error: RawPurchaseError): void => {
567
+ if (error.productId && error.productId !== sku) {
568
+ return;
569
+ }
570
+ finish({ ok: false, reason: mapErrorCode(error.code), detail: error.code });
571
+ };
572
+ const fail = (error: unknown): void => {
573
+ const code = (error as { code?: string } | undefined)?.code;
574
+ finish({ ok: false, reason: mapErrorCode(code), detail: code });
575
+ };
576
+ function finish(outcome: ProviderPurchaseOutcome): void {
577
+ if (settled) {
578
+ return;
579
+ }
580
+ settled = true;
581
+ clearTimeout(grace);
582
+ purchaseListeners.delete(onPurchase);
583
+ purchaseErrorListeners.delete(onError);
584
+ resolve(outcome);
490
585
  }
491
- return { ok: true, purchase: normalizePurchase(raw as RawPurchase) };
492
- } catch (error) {
493
- const code = (error as { code?: string }).code;
494
- return { ok: false, reason: mapErrorCode(code), detail: code };
495
- }
586
+
587
+ purchaseListeners.add(onPurchase);
588
+ purchaseErrorListeners.add(onError);
589
+ try {
590
+ module()
591
+ .requestPurchase(toRequestProps(request))
592
+ .then(() => {
593
+ if (!settled) {
594
+ grace = setTimeout(
595
+ () =>
596
+ finish({
597
+ ok: false,
598
+ reason: 'store_error',
599
+ detail: 'no purchase event',
600
+ }),
601
+ RESULT_EVENT_GRACE_MS,
602
+ );
603
+ }
604
+ }, fail);
605
+ } catch (error) {
606
+ fail(error);
607
+ }
608
+ });
496
609
  },
497
610
 
498
611
  observePurchases(listener): Unsubscribe {
@@ -102,7 +102,12 @@ export interface ProviderPurchaseRequest {
102
102
  offerKey?: string;
103
103
  /** App-supplied, passed straight through. Never generated or stored (FR-034). */
104
104
  obfuscatedAccountId?: string;
105
- /** Plan change: the currently-active subscription being replaced. */
105
+ /**
106
+ * Plan change: the token of the active subscription being replaced. The store
107
+ * migrates *that* purchase rather than creating a second one (FR-065).
108
+ */
109
+ replacePurchaseToken?: ProviderToken;
110
+ /** Plan change: the store product id of the subscription being replaced. */
106
111
  replaceStoreProductId?: StoreProductId;
107
112
  prorationMode?: string;
108
113
  }
@@ -138,6 +143,18 @@ export interface ProviderCapabilities {
138
143
  * a development build.
139
144
  */
140
145
  createsRealTransactions: boolean;
146
+ /**
147
+ * Whether `launchPurchase` settles only once the user has left the store's
148
+ * purchase surface.
149
+ *
150
+ * `true` for the Play adapter: the binding gives no "surface opened" signal and
151
+ * reports the outcome only when the flow ends, so the time the user spends in
152
+ * Play is inside the call. The engine then does not apply its launch timeout,
153
+ * which would otherwise cancel a user still choosing a payment method (FR-035).
154
+ * The binding always ends the flow with a result or an error, so the call
155
+ * cannot hang. `false` for the fake store, whose launch returns at once.
156
+ */
157
+ purchaseOutcomeIncludesStoreTime: boolean;
141
158
  }
142
159
 
143
160
  export interface ProviderPort {
@@ -63,7 +63,14 @@ export interface TimeoutConfig {
63
63
  syncMs?: number;
64
64
  /** Covers opening the store surface only. Time the user spends in it is not counted (FR-035). */
65
65
  purchaseLaunchMs?: number;
66
+ /** Bounds each acknowledge or consume call; a timed-out attempt is retried. */
66
67
  finalizeAttemptMs?: number;
68
+ /**
69
+ * Bounds the app's `validateTransaction` hook. A hook that does not answer in
70
+ * time is treated as unavailable, exactly like one that throws (FR-072b).
71
+ * Added in 0.3.2.
72
+ */
73
+ validateMs?: number;
67
74
  }
68
75
 
69
76
  export interface RetryConfig {
@@ -88,6 +95,14 @@ export interface ValidationCandidate {
88
95
  verificationPayload: string;
89
96
  /** Ties this decision to one purchase cycle in the event stream (FR-076). */
90
97
  correlationId: string;
98
+ /**
99
+ * The store's identifier for this transaction (Play's order id). Not a secret,
100
+ * and the only handle a backend has on re-validation, where
101
+ * `verificationPayload` is empty because it is never persisted. Added in 0.3.2.
102
+ */
103
+ transactionId: string;
104
+ /** Which store issued the transaction, so a backend picks the right API. Added in 0.3.2. */
105
+ platform: 'android' | 'ios';
91
106
  }
92
107
 
93
108
  export interface ValidationDecision {
@@ -34,6 +34,16 @@ export const DEFAULT_SYNC_TIMEOUT_MS = 20_000;
34
34
  export const DEFAULT_PURCHASE_LAUNCH_TIMEOUT_MS = 30_000;
35
35
  /** Per finalize attempt, not per transaction — the queue owns overall persistence. */
36
36
  export const DEFAULT_FINALIZE_ATTEMPT_TIMEOUT_MS = 15_000;
37
+ /** The app's backend hook. A user is waiting on a purchase result behind it. */
38
+ export const DEFAULT_VALIDATE_TIMEOUT_MS = 15_000;
39
+ /**
40
+ * The ceiling on a purchase flow whose outcome includes the user's own time in
41
+ * the store UI, so is otherwise untimed (FR-035). Not a timeout on the user: it
42
+ * exists only so a binding that never answers cannot hold the purchase lock for
43
+ * the rest of the session. A purchase that completes later still arrives through
44
+ * the store update channel and is granted then.
45
+ */
46
+ export const PURCHASE_FLOW_CEILING_MS = 10 * 60_000;
37
47
 
38
48
  export const CONNECT_TIMEOUT_BOUNDS = { min: 3_000, max: 60_000 } as const;
39
49
  export const PRODUCT_QUERY_TIMEOUT_BOUNDS = { min: 3_000, max: 60_000 } as const;
@@ -46,6 +56,7 @@ export const FINALIZE_ATTEMPT_TIMEOUT_BOUNDS = {
46
56
  min: 3_000,
47
57
  max: 60_000,
48
58
  } as const;
59
+ export const VALIDATE_TIMEOUT_BOUNDS = { min: 3_000, max: 120_000 } as const;
49
60
 
50
61
  /**
51
62
  * Prices and offer eligibility are shown at the moment of purchase, so a short
@@ -138,6 +149,7 @@ export interface ResolvedTimeouts {
138
149
  syncMs: number;
139
150
  purchaseLaunchMs: number;
140
151
  finalizeAttemptMs: number;
152
+ validateMs: number;
141
153
  }
142
154
 
143
155
  export function resolveTimeouts(config?: TimeoutConfig): ResolvedTimeouts {
@@ -172,6 +184,12 @@ export function resolveTimeouts(config?: TimeoutConfig): ResolvedTimeouts {
172
184
  DEFAULT_FINALIZE_ATTEMPT_TIMEOUT_MS,
173
185
  FINALIZE_ATTEMPT_TIMEOUT_BOUNDS,
174
186
  ),
187
+ validateMs: requireInBounds(
188
+ 'timeouts.validateMs',
189
+ config?.validateMs,
190
+ DEFAULT_VALIDATE_TIMEOUT_MS,
191
+ VALIDATE_TIMEOUT_BOUNDS,
192
+ ),
175
193
  };
176
194
  }
177
195
 
@@ -29,6 +29,8 @@ export const PAYMENT_ERROR_CODES = [
29
29
  'PAYMENT_VALIDATION_REJECTED',
30
30
  'PAYMENT_VALIDATION_UNAVAILABLE',
31
31
  'PAYMENT_STORAGE_UNAVAILABLE',
32
+ /** `consume` on a transaction that is not a consumable, added in 0.3.2. */
33
+ 'PAYMENT_NOT_CONSUMABLE',
32
34
  ] as const;
33
35
 
34
36
  export type PaymentErrorCode = (typeof PAYMENT_ERROR_CODES)[number];
@@ -98,13 +100,18 @@ export class PaymentConfigError extends PaymentError {
98
100
  *
99
101
  * Applied at the boundary rather than trusted to call sites: a single forgotten
100
102
  * interpolation is how receipts end up in logs (NFR-006).
103
+ *
104
+ * Identifier-only: what survives is what code defined, never what data carried.
105
+ * A `PaymentError` keeps its code and an `Error` its class name; every message
106
+ * and every string is dropped, because a store or consumer message can embed a
107
+ * purchase token or an account id and nothing here can tell which.
101
108
  */
102
109
  export function redact(value: unknown): string {
103
- if (value instanceof Error) {
104
- return value.message;
110
+ if (value instanceof PaymentError) {
111
+ return value.code;
105
112
  }
106
- if (typeof value === 'string') {
107
- return value;
113
+ if (value instanceof Error) {
114
+ return value.name;
108
115
  }
109
116
  return '[redacted]';
110
117
  }
@@ -186,7 +186,8 @@ export function createEngine(
186
186
  // Both run before anything connects: a bad catalog or an unsafe environment
187
187
  // must fail at configuration time, not when a user taps Buy.
188
188
  assertEnvironmentSafe(config);
189
- const resolved = validateConfig(config, deps.platform ?? 'android');
189
+ const platform = deps.platform ?? 'android';
190
+ const resolved = validateConfig(config, platform);
190
191
 
191
192
  const now = deps.now ?? (() => Date.now());
192
193
  const provider = deps.provider;
@@ -204,6 +205,8 @@ export function createEngine(
204
205
 
205
206
  const initFlight = createSingleFlight<InitOutcome>();
206
207
  const syncFlight = createSingleFlight<SyncOutcome>();
208
+ /** Finalize drain and revalidation, joined the same way but outside the sync flight. */
209
+ const settleFlight = createSingleFlight<void>();
207
210
  const foregroundLimiter = createRateLimiter(
208
211
  resolved.foregroundSyncMinIntervalMs,
209
212
  now,
@@ -227,6 +230,12 @@ export function createEngine(
227
230
  let purchaseSub: Unsubscribe | undefined;
228
231
  let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
229
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;
230
239
  // Exclusive rather than single-flight: a second purchase must be refused, not
231
240
  // joined onto the first, and never queued (FR-031).
232
241
  const purchaseLock = createExclusive();
@@ -425,7 +434,14 @@ export function createEngine(
425
434
  };
426
435
 
427
436
  const scheduleReconnect = (): void => {
428
- if (disposed || reconnectAttempts >= resolved.retry.reconnect.maxAttempts) {
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 });
429
445
  return;
430
446
  }
431
447
  const attempt = reconnectAttempts + 1;
@@ -468,6 +484,7 @@ export function createEngine(
468
484
  const finalizer = createFinalizer({
469
485
  provider,
470
486
  retry: resolved.retry,
487
+ attemptTimeoutMs: resolved.timeouts.finalizeAttemptMs,
471
488
  snapshot: () => currentSnapshot,
472
489
  commit,
473
490
  emit,
@@ -486,6 +503,7 @@ export function createEngine(
486
503
  config,
487
504
  resolved,
488
505
  provider,
506
+ platform,
489
507
  finalizer,
490
508
  snapshot: () => currentSnapshot,
491
509
  commit,
@@ -521,6 +539,8 @@ export function createEngine(
521
539
  * purchase is open.
522
540
  */
523
541
  const watchPurchases = (): void => {
542
+ // Replaced, never stacked: a second subscription would ingest every update twice.
543
+ purchaseSub?.();
524
544
  purchaseSub = provider.observePurchases(purchase => {
525
545
  if (disposed) {
526
546
  return;
@@ -530,6 +550,7 @@ export function createEngine(
530
550
  };
531
551
 
532
552
  const watchConnection = (): void => {
553
+ connectionSub?.();
533
554
  connectionSub = provider.observeConnection((connected, reason) => {
534
555
  if (disposed) {
535
556
  return;
@@ -567,6 +588,13 @@ export function createEngine(
567
588
  };
568
589
  }
569
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
+
570
598
  return initFlight.run(async () => {
571
599
  if (disposed) {
572
600
  return {
@@ -639,6 +667,7 @@ export function createEngine(
639
667
 
640
668
  watchConnection();
641
669
  watchPurchases();
670
+ everConnected = true;
642
671
  setStatus({ kind: 'ready', reconnectAttempts: 0 });
643
672
  await engine.sync('initialize');
644
673
  // Drain after syncing, so a transaction the store still reports as owned
@@ -753,7 +782,7 @@ export function createEngine(
753
782
 
754
783
  // Concurrent callers join one flight, so repeated syncs make exactly one
755
784
  // store request (FR-063).
756
- return syncFlight.run(async () => {
785
+ const outcome = await syncFlight.run(async () => {
757
786
  try {
758
787
  const owned = await withTimeout(
759
788
  provider.queryOwned(),
@@ -806,17 +835,6 @@ export function createEngine(
806
835
  trigger,
807
836
  });
808
837
 
809
- // Every sync is also a chance to settle what a previous session left
810
- // owed. A pending purchase that completed while the app was closed
811
- // arrives here, and so does a transaction whose acknowledgement failed
812
- // last time (FR-039, FR-042).
813
- await finalizer.drain();
814
-
815
- // And a chance to settle a backend decision we had to defer. Done on
816
- // sync rather than on a timer of ours, so recovery follows the app's
817
- // own rhythm (FR-072a).
818
- await purchaseFlow.revalidate();
819
-
820
838
  return { ok: true as const, restoredCount, revokedCount };
821
839
  } catch (error) {
822
840
  const paymentError =
@@ -838,6 +856,23 @@ export function createEngine(
838
856
  return { ok: false as const, error: paymentError };
839
857
  }
840
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;
841
876
  },
842
877
 
843
878
  async restore(): Promise<RestoreOutcome> {
@@ -922,6 +957,19 @@ export function createEngine(
922
957
  },
923
958
 
924
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
+ }
925
973
  // Rate-limited inside sync, so an app that calls this on every incidental
926
974
  // foreground transition still produces at most one request per interval.
927
975
  await engine.sync('foreground');