@chipmobilesdk/rn-payment 0.3.1 → 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.
@@ -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');
@@ -25,6 +25,8 @@ import type {
25
25
  import type { ProviderPort } from '../adapter/port';
26
26
  import type { ResolvedRetry } from '../core/timers';
27
27
  import { backoffDelayMs } from '../core/retry';
28
+ import { withTimeout } from '../core/mutex';
29
+ import { PaymentError } from '../diagnostics/errors';
28
30
  import {
29
31
  pendingFinalizations,
30
32
  type PersistedPurchase,
@@ -35,6 +37,8 @@ import type { PaymentEventInput } from '../diagnostics/events';
35
37
  export interface FinalizerDeps {
36
38
  provider: ProviderPort;
37
39
  retry: ResolvedRetry;
40
+ /** `timeouts.finalizeAttemptMs`: one acknowledge or consume call may take no longer. */
41
+ attemptTimeoutMs: number;
38
42
  snapshot: () => PersistedSnapshot;
39
43
  commit: (next: PersistedSnapshot) => void;
40
44
  emit: (event: PaymentEventInput) => void;
@@ -86,6 +90,25 @@ export function createFinalizer(deps: FinalizerDeps): Finalizer {
86
90
  });
87
91
  };
88
92
 
93
+ /**
94
+ * One store call under `attemptTimeoutMs`. A call that never settles used to
95
+ * hold this transaction's in-flight guard and, through `drain`, the sync that
96
+ * awaited it. Timing out counts as a failed attempt, so the retry schedule and
97
+ * the next-session drain both still apply.
98
+ */
99
+ const bounded = (call: Promise<void>, operation: 'finalize' | 'consume'): Promise<void> =>
100
+ withTimeout(
101
+ call,
102
+ deps.attemptTimeoutMs,
103
+ () =>
104
+ new PaymentError({
105
+ code: 'PAYMENT_TIMEOUT',
106
+ message: `The store did not answer the ${operation} call in time`,
107
+ operation,
108
+ retryable: true,
109
+ }),
110
+ );
111
+
89
112
  const clearTimer = (transactionId: string): void => {
90
113
  const timer = timers.get(transactionId);
91
114
  if (timer !== undefined) {
@@ -128,7 +151,7 @@ export function createFinalizer(deps: FinalizerDeps): Finalizer {
128
151
 
129
152
  let failed = false;
130
153
  try {
131
- await deps.provider.acknowledge(record.token);
154
+ await bounded(deps.provider.acknowledge(record.token), 'finalize');
132
155
  } catch {
133
156
  failed = true;
134
157
  } finally {
@@ -157,7 +180,72 @@ export function createFinalizer(deps: FinalizerDeps): Finalizer {
157
180
  }
158
181
 
159
182
  patch(transactionId, { finalizeState: 'failed', attempts });
160
- return scheduleRetry(transactionId, record.productKey, attempts);
183
+ return scheduleRetry(transactionId, record.productKey, attempts, 'acknowledge');
184
+ };
185
+
186
+ /**
187
+ * One consume call for a consumable the app has banked (FR-043).
188
+ *
189
+ * Refuses anything that is not a settled consumable. Consuming a subscription
190
+ * or a permanent unlock would tell the store the user used it up, and a
191
+ * pending purchase has nothing to consume yet.
192
+ */
193
+ const consumeAttempt = async (transactionId: string): Promise<FinalizeResult> => {
194
+ if (deps.disposed()) {
195
+ return { kind: 'failed', code: 'PAYMENT_NOT_CONFIGURED' };
196
+ }
197
+ const record = find(transactionId);
198
+ if (!record) {
199
+ return { kind: 'unknown_transaction' };
200
+ }
201
+ if (record.productType !== 'one_time_consumable') {
202
+ return { kind: 'failed', code: 'PAYMENT_NOT_CONSUMABLE' };
203
+ }
204
+ if (record.state === 'pending') {
205
+ return { kind: 'failed', code: 'PAYMENT_PENDING' };
206
+ }
207
+ if (record.state === 'consumed') {
208
+ // Consuming twice must grant the item once (FR-044).
209
+ return { kind: 'already_finalized' };
210
+ }
211
+ if (inFlight.has(transactionId)) {
212
+ // Concurrent consume calls collapse to one, for the same reason.
213
+ return { kind: 'already_finalized' };
214
+ }
215
+
216
+ inFlight.add(transactionId);
217
+ patch(transactionId, { finalizeState: 'in_flight' });
218
+
219
+ let failed = false;
220
+ try {
221
+ await bounded(deps.provider.consume(record.token), 'consume');
222
+ } catch {
223
+ failed = true;
224
+ } finally {
225
+ // Released before scheduling, for the same reason as in `attempt`.
226
+ inFlight.delete(transactionId);
227
+ }
228
+
229
+ const attempts = record.attempts + 1;
230
+
231
+ if (failed) {
232
+ patch(transactionId, { finalizeState: 'failed', attempts });
233
+ return scheduleRetry(transactionId, record.productKey, attempts, 'consume');
234
+ }
235
+
236
+ clearTimer(transactionId);
237
+ // `consumed` is what stops it being an entitlement source, so a
238
+ // consumable never becomes a permanent unlock.
239
+ patch(transactionId, {
240
+ state: 'consumed',
241
+ finalizeState: 'done',
242
+ attempts,
243
+ });
244
+ deps.emit({
245
+ type: 'transaction_consumed',
246
+ productKey: record.productKey,
247
+ });
248
+ return { kind: 'finalized' };
161
249
  };
162
250
 
163
251
  /**
@@ -172,6 +260,10 @@ export function createFinalizer(deps: FinalizerDeps): Finalizer {
172
260
  transactionId: string,
173
261
  productKey: ProductKey,
174
262
  attempts: number,
263
+ // The timer must repeat the call that failed. Retrying a failed consume
264
+ // through `attempt` was a no-op — `attempt` skips consumables — while the
265
+ // event still reported a retry as scheduled.
266
+ operation: 'acknowledge' | 'consume',
175
267
  ): FinalizeResult => {
176
268
  if (deps.disposed()) {
177
269
  return { kind: 'failed', code: 'PAYMENT_FINALIZE_FAILED' };
@@ -187,7 +279,8 @@ export function createFinalizer(deps: FinalizerDeps): Finalizer {
187
279
  transactionId,
188
280
  setTimeoutFn(() => {
189
281
  timers.delete(transactionId);
190
- attempt(transactionId).catch(() => undefined);
282
+ const retry = operation === 'consume' ? consumeAttempt : attempt;
283
+ retry(transactionId).catch(() => undefined);
191
284
  }, delayMs),
192
285
  );
193
286
 
@@ -229,53 +322,8 @@ export function createFinalizer(deps: FinalizerDeps): Finalizer {
229
322
  }
230
323
  },
231
324
 
232
- async consume(ref: TransactionRef): Promise<FinalizeResult> {
233
- const record = find(ref.transactionId);
234
- if (!record) {
235
- return { kind: 'unknown_transaction' };
236
- }
237
- if (record.state === 'consumed') {
238
- // Consuming twice must grant the item once (FR-044).
239
- return { kind: 'already_finalized' };
240
- }
241
- if (inFlight.has(ref.transactionId)) {
242
- // Concurrent consume calls collapse to one, for the same reason.
243
- return { kind: 'already_finalized' };
244
- }
245
-
246
- inFlight.add(ref.transactionId);
247
- patch(ref.transactionId, { finalizeState: 'in_flight' });
248
-
249
- let failed = false;
250
- try {
251
- await deps.provider.consume(record.token);
252
- } catch {
253
- failed = true;
254
- } finally {
255
- // Released before scheduling, for the same reason as in `attempt`.
256
- inFlight.delete(ref.transactionId);
257
- }
258
-
259
- const attempts = record.attempts + 1;
260
-
261
- if (failed) {
262
- patch(ref.transactionId, { finalizeState: 'failed', attempts });
263
- return scheduleRetry(ref.transactionId, record.productKey, attempts);
264
- }
265
-
266
- clearTimer(ref.transactionId);
267
- // `consumed` is what stops it being an entitlement source, so a
268
- // consumable never becomes a permanent unlock.
269
- patch(ref.transactionId, {
270
- state: 'consumed',
271
- finalizeState: 'done',
272
- attempts,
273
- });
274
- deps.emit({
275
- type: 'transaction_consumed',
276
- productKey: record.productKey,
277
- });
278
- return { kind: 'finalized' };
325
+ consume(ref: TransactionRef): Promise<FinalizeResult> {
326
+ return consumeAttempt(ref.transactionId);
279
327
  },
280
328
 
281
329
  unfinalized(): UnfinalizedSummary[] {
@@ -23,7 +23,7 @@ import type {
23
23
  PurchaseResult,
24
24
  TransactionRef,
25
25
  } from '../config/types';
26
- import type { ResolvedConfig } from '../config/validate';
26
+ import type { ResolvedConfig, TargetPlatform } from '../config/validate';
27
27
  import type {
28
28
  ProviderFailureReason,
29
29
  ProviderPort,
@@ -34,6 +34,7 @@ import type {
34
34
  } from '../adapter/port';
35
35
  import type { ExclusiveOutcome } from '../core/mutex';
36
36
  import { withTimeout } from '../core/mutex';
37
+ import { PURCHASE_FLOW_CEILING_MS } from '../core/timers';
37
38
  import { PaymentError } from '../diagnostics/errors';
38
39
  import type { PaymentEventInput } from '../diagnostics/events';
39
40
  import {
@@ -48,6 +49,7 @@ export interface PurchaseFlowDeps {
48
49
  config: PaymentConfig;
49
50
  resolved: ResolvedConfig;
50
51
  provider: ProviderPort;
52
+ platform: TargetPlatform;
51
53
  finalizer: Finalizer;
52
54
  snapshot: () => PersistedSnapshot;
53
55
  commit: (next: PersistedSnapshot) => void;
@@ -162,6 +164,29 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
162
164
  };
163
165
  };
164
166
 
167
+ /**
168
+ * Calls the app's hook under `timeouts.validateMs`.
169
+ *
170
+ * Unbounded, a hook that never answers held the purchase lock and, through
171
+ * revalidation, the sync flight for the rest of the session. A timeout rejects,
172
+ * so callers treat it exactly like a hook that throws: unavailable (FR-072b).
173
+ */
174
+ const askHook = (
175
+ hook: NonNullable<PaymentConfig['validateTransaction']>,
176
+ candidate: Parameters<NonNullable<PaymentConfig['validateTransaction']>>[0],
177
+ ) =>
178
+ withTimeout(
179
+ hook(candidate),
180
+ resolved.timeouts.validateMs,
181
+ () =>
182
+ new PaymentError({
183
+ code: 'PAYMENT_VALIDATION_UNAVAILABLE',
184
+ message: 'The validation hook did not answer in time',
185
+ operation: 'validate',
186
+ retryable: true,
187
+ }),
188
+ );
189
+
165
190
  /**
166
191
  * Runs the app's backend check, if configured.
167
192
  *
@@ -185,11 +210,13 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
185
210
 
186
211
  const entry = resolved.entries.get(productKey);
187
212
  try {
188
- const decision = await hook({
213
+ const decision = await askHook(hook, {
189
214
  productKey,
190
215
  productType: entry?.productType ?? 'one_time_permanent',
191
216
  verificationPayload: purchase.verificationPayload ?? '',
192
217
  correlationId,
218
+ transactionId: purchase.transactionId,
219
+ platform: deps.platform,
193
220
  });
194
221
  deps.emit({
195
222
  type: 'validation_decided',
@@ -209,6 +236,32 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
209
236
  }
210
237
  };
211
238
 
239
+ /**
240
+ * Settlements in progress, by transaction.
241
+ *
242
+ * The Play binding delivers a launched purchase twice: to the launch and, at
243
+ * the same moment, to the session-long update listener. Both paths reach
244
+ * `settle`, and without this the hook ran twice and the purchase was settled
245
+ * twice. The second caller joins the first and gets the same result.
246
+ */
247
+ const inFlightSettle = new Map<string, Promise<PurchaseResult>>();
248
+
249
+ const settleOnce = (
250
+ purchase: ProviderPurchase,
251
+ productKey: ProductKey,
252
+ correlationId: string,
253
+ ): Promise<PurchaseResult> => {
254
+ const running = inFlightSettle.get(purchase.transactionId);
255
+ if (running) {
256
+ return running;
257
+ }
258
+ const flight = settle(purchase, productKey, correlationId).finally(() => {
259
+ inFlightSettle.delete(purchase.transactionId);
260
+ });
261
+ inFlightSettle.set(purchase.transactionId, flight);
262
+ return flight;
263
+ };
264
+
212
265
  /** Persists a completed purchase, grants it, then finalizes. */
213
266
  const settle = async (
214
267
  purchase: ProviderPurchase,
@@ -264,8 +317,16 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
264
317
  purchase: ProviderPurchase,
265
318
  productKey: ProductKey,
266
319
  ): PurchaseResult => {
320
+ // The same pending transaction arrives from the launch and from the update
321
+ // listener (see `inFlightSettle`). The signal is about a purchase becoming
322
+ // pending, so it fires once per transaction, not once per delivery.
323
+ const alreadyPending = deps
324
+ .snapshot()
325
+ .purchases.some(p => p.transactionId === purchase.transactionId && p.state === 'pending');
267
326
  upsert(recordFor(purchase, productKey));
268
- deps.emit({ type: 'purchase_pending', productKey });
327
+ if (!alreadyPending) {
328
+ deps.emit({ type: 'purchase_pending', productKey });
329
+ }
269
330
  return {
270
331
  kind: 'pending',
271
332
  productKey,
@@ -326,18 +387,16 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
326
387
  /**
327
388
  * The launch timeout covers opening the store surface only; time the user
328
389
  * spends inside it is the store's, not ours (FR-035). A provider that reports
329
- * nothing until the flow ends gives no "opened" moment to time, so it is not
330
- * timed at all: the timeout would cut into the user's time, and that provider
331
- * always ends the flow with a result.
390
+ * nothing until the flow ends gives no "opened" moment to time, so it gets only
391
+ * `PURCHASE_FLOW_CEILING_MS`: far beyond any real purchase, there so a binding
392
+ * that never answers cannot hold the purchase lock for the whole session.
332
393
  */
333
394
  const launch = (request: ProviderPurchaseRequest): Promise<ProviderPurchaseOutcome> => {
334
395
  const launched = deps.provider.launchPurchase(request);
335
- if (deps.provider.capabilities().purchaseOutcomeIncludesStoreTime) {
336
- return launched;
337
- }
396
+ const includesStoreTime = deps.provider.capabilities().purchaseOutcomeIncludesStoreTime;
338
397
  return withTimeout(
339
398
  launched,
340
- resolved.timeouts.purchaseLaunchMs,
399
+ includesStoreTime ? PURCHASE_FLOW_CEILING_MS : resolved.timeouts.purchaseLaunchMs,
341
400
  () =>
342
401
  new PaymentError({
343
402
  code: 'PAYMENT_TIMEOUT',
@@ -447,7 +506,7 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
447
506
  }
448
507
 
449
508
  deps.emit({ type: 'purchase_succeeded', productKey, correlationId });
450
- return settle(outcome.purchase, productKey, correlationId);
509
+ return settleOnce(outcome.purchase, productKey, correlationId);
451
510
  });
452
511
 
453
512
  if (!attempt.acquired) {
@@ -492,7 +551,7 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
492
551
  return;
493
552
  }
494
553
 
495
- await settle(purchase, productKey, deps.nextCorrelationId());
554
+ await settleOnce(purchase, productKey, deps.nextCorrelationId());
496
555
  },
497
556
 
498
557
  async revalidate(): Promise<void> {
@@ -509,14 +568,17 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
509
568
  const entry = resolved.entries.get(record.productKey);
510
569
  let approved: boolean;
511
570
  try {
512
- const decision = await hook({
571
+ const decision = await askHook(hook, {
513
572
  productKey: record.productKey,
514
573
  productType: entry?.productType ?? 'one_time_permanent',
515
574
  // Re-validation deliberately carries no payload: the original
516
575
  // verification material is not persisted (FR-056), so the backend
517
- // re-checks by transaction against its own record of the purchase.
576
+ // re-checks by transaction against its own record of the purchase —
577
+ // which is why the transaction id has to travel with it.
518
578
  verificationPayload: '',
519
579
  correlationId: deps.nextCorrelationId(),
580
+ transactionId: record.transactionId,
581
+ platform: deps.platform,
520
582
  });
521
583
  approved = decision.approved;
522
584
  } catch {
@@ -32,6 +32,13 @@ export interface FakeStoreOptions {
32
32
  capabilities?: Partial<ProviderCapabilities>;
33
33
  products?: ProviderProduct[];
34
34
  owned?: ProviderPurchase[];
35
+ /**
36
+ * `'android-16.7'` mirrors the Play binding the real adapter wraps: a launched
37
+ * purchase is also delivered through `observePurchases`, so the engine sees it
38
+ * twice, and capabilities match Android's. The default delivers it once, which
39
+ * is simpler but hides any double-settle on the device path.
40
+ */
41
+ mode?: 'default' | 'android-16.7';
35
42
  }
36
43
 
37
44
  export interface FakeStore extends ProviderPort {
@@ -127,8 +134,10 @@ export function createFakeStore(options: FakeStoreOptions = {}): FakeStore {
127
134
  let connectOutcome: ProviderConnectOutcome = options.connectOutcome ?? {
128
135
  ok: true,
129
136
  };
137
+ const androidBinding = options.mode === 'android-16.7';
130
138
  let capabilities: ProviderCapabilities = {
131
139
  ...DEFAULT_CAPABILITIES,
140
+ ...(androidBinding ? { supportsPriceChangeConfirmation: false } : {}),
132
141
  ...options.capabilities,
133
142
  };
134
143
  let products = [...(options.products ?? [])];
@@ -231,6 +240,13 @@ export function createFakeStore(options: FakeStoreOptions = {}): FakeStore {
231
240
  ...script.overrides,
232
241
  });
233
242
  owned = [...owned.filter(p => p.transactionId !== purchase.transactionId), purchase];
243
+ if (androidBinding) {
244
+ // The binding's update listener fires for every observer, the session-long
245
+ // one included, before the launch settles.
246
+ for (const listener of [...purchaseListeners]) {
247
+ listener(purchase);
248
+ }
249
+ }
234
250
  return { ok: true, purchase };
235
251
  },
236
252