@chipmobilesdk/rn-payment 0.3.0 → 0.3.1

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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,54 @@ product keys, error codes, event names, state values, message keys, and the
7
7
  public API shape are this package's contract: renaming any of them is a breaking
8
8
  change.
9
9
 
10
+ ## [0.3.1] — 2026-10-25
11
+
12
+ Patch release. **Upgrade before shipping on Android**: with `0.3.0` no purchase could
13
+ complete on a device. Found on a Galaxy S25 Ultra with a licence-tester account, in the
14
+ first app to buy through this package (Task Manager, internal testing track).
15
+
16
+ ### Fixed
17
+
18
+ - **Every purchase failed before Play opened.** The adapter sent
19
+ `request: { android: { skus, obfuscatedAccountIdAndroid } }`, the request shape from
20
+ `react-native-iap`'s documentation before 16.7. The 16.7 binding reads
21
+ `request.google` and rejected the call with `empty-sku-list`; the app saw a store
22
+ error. The request now uses `google` and `obfuscatedAccountId`, and is typed with the
23
+ binding's own `RequestPurchaseProps`, so a renamed key fails to typecheck.
24
+ - **A completed purchase would have been reported as a store error.** On Android
25
+ `requestPurchase` resolves with an empty list when the flow ends, whatever happened;
26
+ the outcome arrives only through `purchaseUpdatedListener` / `purchaseErrorListener`.
27
+ The adapter read the resolved value, so it would have reported `store_error` for
28
+ every purchase, while Play delivered the purchase separately. `launchPurchase` now
29
+ settles on the first outcome event for the product (bought, pending, or an error).
30
+ - **"Already owned" was a generic store error.** The adapter matched
31
+ `item-already-owned`; 16.7 sends `already-owned`. A reinstall that taps Buy again
32
+ now gets `already_owned` and the self-repairing sync (FR-033). `sku-not-found` now
33
+ maps to `unknown_product`.
34
+ - **The launch timeout cut into the user's time in Play (FR-035).** Because the
35
+ binding reports nothing until the flow ends, the 30-second "opening the store
36
+ surface" timeout covered the time the user spends choosing a payment method. The
37
+ engine no longer applies it to a provider whose outcome includes that time (the Play
38
+ adapter); the binding always ends the flow with an event, so the call cannot hang.
39
+ - **Plan changes sent the removed 16.6 fields.** A plan change now sends the replaced
40
+ purchase's token as `purchaseToken` and, with a proration mode,
41
+ `subscriptionProductReplacementParams` (old product id + replacement mode).
42
+
43
+ ### Changed
44
+
45
+ - Peer `react-native-iap` is now `>=16.7.3 <17`, the version this adapter was verified
46
+ against (was `>=16.6.1 <17`, never verified).
47
+ - Internal: `ProviderPurchaseRequest.replaceStoreProductId` now holds the replaced
48
+ product's id and the token moved to `replacePurchaseToken` (it held the token before);
49
+ `ProviderCapabilities` gained `purchaseOutcomeIncludesStoreTime`. Neither type is
50
+ public API. The fake store keeps `false`, so launch-timeout tests behave as before.
51
+
52
+ ### Added
53
+
54
+ - `__tests__/iap-adapter.test.ts`: the adapter against a fake binding that validates
55
+ and resolves as 16.7.3 does — request shape, event-based outcomes, error mapping, the
56
+ no-event fallback.
57
+
10
58
  ## [0.3.0] — 2026-10-13
11
59
 
12
60
  Documentation release: the README and the agent skill now match the code `0.2.1` shipped.
package/README.md CHANGED
@@ -112,7 +112,7 @@ Verified against this repository's toolchain, not assumed:
112
112
  | Node | >= 22.11.0 |
113
113
  | Android `minSdk` / `compileSdk` / `targetSdk` | 24 / 36 / 36 |
114
114
  | Kotlin | 2.1.20 |
115
- | `react-native-iap` | >= 16.6.1 < 17 |
115
+ | `react-native-iap` | >= 16.7.3 < 17 (verified 16.7.3) |
116
116
  | `react-native-mmkv` | >= 4.0.0 |
117
117
  | `react-native-nitro-modules` | >= 0.36.5 |
118
118
 
@@ -120,16 +120,18 @@ Verified against this repository's toolchain, not assumed:
120
120
  requirement and the bundled Play Billing version move on majors, so a major bump
121
121
  needs re-verification rather than silent adoption.
122
122
 
123
- > ⚠️ **The Android adapter is written but not yet verified on a device.** Its call
124
- > shapes follow `react-native-iap` 16.x as documented, and the surface it depends on
125
- > is declared explicitly in a local `IapModule` interface so a mismatch fails at
126
- > typecheck rather than at runtime on a user's device. Verify it on a device before
127
- > shipping.
123
+ > ⚠️ **Use `0.3.1` or later.** Up to `0.3.0` the Android adapter followed
124
+ > `react-native-iap` 16.x as documented, not as installed, and on a device **no
125
+ > purchase could complete**: the request used the pre-16.7 `android` key, which the
126
+ > binding rejects before Play opens. `0.3.1` was checked call by call against the
127
+ > installed 16.7.3 source, types its request with the binding's own
128
+ > `RequestPurchaseProps` (a renamed key now fails to typecheck), and is tested
129
+ > against a fake binding that behaves as 16.7.3 does. Still confirm one licence-tester
130
+ > purchase on a device before shipping.
128
131
  >
129
- > One part *has* been checked against an installed binding (16.7.3): how a purchase's
130
- > state is read. That check found a defect in `0.1.0` — the binding sends the state as
131
- > a string, the adapter expected a number, and a pending purchase was granted. Fixed
132
- > in `0.2.0`; see the changelog.
132
+ > Earlier, the same check of how a purchase's state is read found a defect in `0.1.0`
133
+ > — the binding sends the state as a string, the adapter expected a number, and a
134
+ > pending purchase was granted. Fixed in `0.2.0`; see the changelog.
133
135
 
134
136
  ---
135
137
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chipmobilesdk/rn-payment",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "description": "Reusable in-app payment and entitlement capability for ChipMobileSdk React Native apps",
@@ -41,7 +41,7 @@
41
41
  "peerDependencies": {
42
42
  "react": ">=19",
43
43
  "react-native": ">=0.85",
44
- "react-native-iap": ">=16.6.1 <17",
44
+ "react-native-iap": ">=16.7.3 <17",
45
45
  "react-native-mmkv": ">=4.0.0",
46
46
  "react-native-nitro-modules": ">=0.36.5"
47
47
  },
@@ -3,7 +3,7 @@ name: sdk-rn-payment
3
3
  description: "In-app payments via @chipmobilesdk/rn-payment: Play Billing one-time/consumable/subscription behind a store-neutral contract, entitlement keys with a required unknown state, pending purchases visible across sessions, guaranteed transaction finalization, per-environment production-transaction blocking. Use when selling a feature, gating on an entitlement, showing a payment still pending after a restart, debugging a purchase that returns environment_blocked or an entitlement stuck at unknown, mapping Play products to entitlement keys, or answering a Data safety question about purchases."
4
4
  metadata:
5
5
  package: "@chipmobilesdk/rn-payment"
6
- packageVersion: "0.3.0"
6
+ packageVersion: "0.3.1"
7
7
  user-invocable: true
8
8
  disable-model-invocation: false
9
9
  ---
@@ -93,7 +93,7 @@ Unlike `@chipmobilesdk/rn-ads`, there is **no test-id substitution**: Play has n
93
93
  Call `handleForeground()` yourself on app resume — the package will not guess what
94
94
  "foreground" means for a host backgrounded by its own modal or the store's purchase sheet.
95
95
 
96
- ## Public API (v0.3.0)
96
+ ## Public API (v0.3.1)
97
97
 
98
98
  Runtime: `createEngine` (throws `PaymentConfigError` on a bad catalog or an unsafe
99
99
  environment; everything else returns a typed result) · `createStoreProvider` ·
@@ -134,6 +134,12 @@ exhaustive `switch` needs the case or a `default`. A hand-written `PaymentEngine
134
134
  needs the two pending methods. No storage migration (schema unchanged). Replace any
135
135
  app-side "pending since" marker with `usePendingPurchases`.
136
136
 
137
+ **Require `0.3.1` or later on Android.** Up to `0.3.0` no purchase could complete on a
138
+ device: the adapter sent `react-native-iap`'s pre-16.7 request key (`android`), rejected
139
+ before Play opens, and read the binding's always-empty resolved value instead of its
140
+ outcome events. `0.3.1` needs `react-native-iap` >= 16.7.3. If a purchase returns
141
+ `store_error` at once, check the installed binding against that range first.
142
+
137
143
  **Require `0.2.1` or later.** Earlier versions opened MMKV with the v3 constructor, which
138
144
  react-native-mmkv v4 does not have, so on a device nothing persisted: owners read
139
145
  `unknown` after every cold start and pending payments vanished after a kill.
@@ -33,6 +33,7 @@ const UNSUPPORTED_CAPABILITIES: ProviderCapabilities = {
33
33
  supportsExternalTransactions: false,
34
34
  /** Nothing here can charge anyone, so the environment guard has nothing to block. */
35
35
  createsRealTransactions: false,
36
+ purchaseOutcomeIncludesStoreTime: false,
36
37
  };
37
38
 
38
39
  /**
@@ -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
 
@@ -361,6 +447,9 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
361
447
  if (mapErrorCode(error.code) === 'not_ready') {
362
448
  notifyConnection(false, 'not_prepared');
363
449
  }
450
+ for (const listener of purchaseErrorListeners) {
451
+ listener(error);
452
+ }
364
453
  });
365
454
  };
366
455
 
@@ -450,49 +539,69 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
450
539
  return raw.map(normalizePurchase);
451
540
  },
452
541
 
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' };
542
+ /**
543
+ * Settles on the first outcome event for this product: a purchase update
544
+ * (bought or pending) or a purchase error (cancelled, already owned, …).
545
+ *
546
+ * The binding's promise is not the outcome: on Android it resolves with an
547
+ * empty list when the flow ends, whatever happened. Its listeners also feed
548
+ * `observePurchases`, so the engine sees a bought purchase twice — here and
549
+ * as a store update — and grants it once (FR-044).
550
+ */
551
+ launchPurchase(request: ProviderPurchaseRequest): Promise<ProviderPurchaseOutcome> {
552
+ const sku = request.storeProductId;
553
+ return new Promise<ProviderPurchaseOutcome>(resolve => {
554
+ let settled = false;
555
+ let grace: ReturnType<typeof setTimeout> | undefined;
556
+
557
+ const onPurchase = (purchase: ProviderPurchase): void => {
558
+ if (purchase.storeProductId === sku) {
559
+ finish({ ok: true, purchase });
560
+ }
561
+ };
562
+ const onError = (error: RawPurchaseError): void => {
563
+ if (error.productId && error.productId !== sku) {
564
+ return;
565
+ }
566
+ finish({ ok: false, reason: mapErrorCode(error.code), detail: error.code });
567
+ };
568
+ const fail = (error: unknown): void => {
569
+ const code = (error as { code?: string } | undefined)?.code;
570
+ finish({ ok: false, reason: mapErrorCode(code), detail: code });
571
+ };
572
+ function finish(outcome: ProviderPurchaseOutcome): void {
573
+ if (settled) {
574
+ return;
575
+ }
576
+ settled = true;
577
+ clearTimeout(grace);
578
+ purchaseListeners.delete(onPurchase);
579
+ purchaseErrorListeners.delete(onError);
580
+ resolve(outcome);
490
581
  }
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
- }
582
+
583
+ purchaseListeners.add(onPurchase);
584
+ purchaseErrorListeners.add(onError);
585
+ try {
586
+ module()
587
+ .requestPurchase(toRequestProps(request))
588
+ .then(() => {
589
+ if (!settled) {
590
+ grace = setTimeout(
591
+ () =>
592
+ finish({
593
+ ok: false,
594
+ reason: 'store_error',
595
+ detail: 'no purchase event',
596
+ }),
597
+ RESULT_EVENT_GRACE_MS,
598
+ );
599
+ }
600
+ }, fail);
601
+ } catch (error) {
602
+ fail(error);
603
+ }
604
+ });
496
605
  },
497
606
 
498
607
  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 {
@@ -28,6 +28,9 @@ import type {
28
28
  ProviderFailureReason,
29
29
  ProviderPort,
30
30
  ProviderPurchase,
31
+ ProviderPurchaseOutcome,
32
+ ProviderPurchaseRequest,
33
+ StoreProductId,
31
34
  } from '../adapter/port';
32
35
  import type { ExclusiveOutcome } from '../core/mutex';
33
36
  import { withTimeout } from '../core/mutex';
@@ -291,6 +294,60 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
291
294
  return { kind: 'store_error', productKey, code: 'PAYMENT_STORE_ERROR' };
292
295
  };
293
296
 
297
+ /** What the store is asked for: the product and, for a plan change, what it replaces. */
298
+ const launchRequest = (
299
+ productKey: ProductKey,
300
+ storeProductId: StoreProductId,
301
+ options?: InternalPurchaseOptions,
302
+ ): ProviderPurchaseRequest => {
303
+ const replaceKey = options?.replaceProductKey;
304
+ return {
305
+ storeProductId,
306
+ productType: resolved.entries.get(productKey)?.productType ?? 'one_time_permanent',
307
+ offerKey: options?.offerKey,
308
+ obfuscatedAccountId: options?.obfuscatedAccountId,
309
+ // Present only for a plan change. The store needs the token of the
310
+ // subscription being replaced so it migrates the existing purchase instead
311
+ // of creating a second one (FR-065), and its product id to apply the
312
+ // replacement mode.
313
+ replacePurchaseToken: replaceKey
314
+ ? deps
315
+ .snapshot()
316
+ .purchases.find(p => p.productKey === replaceKey && p.state === 'purchased')
317
+ ?.token
318
+ : undefined,
319
+ replaceStoreProductId: replaceKey
320
+ ? storeIdForProductKey(resolved, replaceKey)
321
+ : undefined,
322
+ prorationMode: options?.prorationMode,
323
+ };
324
+ };
325
+
326
+ /**
327
+ * The launch timeout covers opening the store surface only; time the user
328
+ * 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.
332
+ */
333
+ const launch = (request: ProviderPurchaseRequest): Promise<ProviderPurchaseOutcome> => {
334
+ const launched = deps.provider.launchPurchase(request);
335
+ if (deps.provider.capabilities().purchaseOutcomeIncludesStoreTime) {
336
+ return launched;
337
+ }
338
+ return withTimeout(
339
+ launched,
340
+ resolved.timeouts.purchaseLaunchMs,
341
+ () =>
342
+ new PaymentError({
343
+ code: 'PAYMENT_TIMEOUT',
344
+ message: 'Opening the purchase surface timed out',
345
+ operation: 'purchase',
346
+ retryable: true,
347
+ }),
348
+ );
349
+ };
350
+
294
351
  return {
295
352
  async purchase(
296
353
  productKey: ProductKey,
@@ -325,7 +382,6 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
325
382
  };
326
383
  }
327
384
 
328
- const entry = resolved.entries.get(productKey);
329
385
  const correlationId = deps.nextCorrelationId();
330
386
 
331
387
  // Exclusive, not single-flight: a second purchase is refused rather than
@@ -335,37 +391,7 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
335
391
 
336
392
  let outcome;
337
393
  try {
338
- outcome = await withTimeout(
339
- deps.provider.launchPurchase({
340
- storeProductId,
341
- productType: entry?.productType ?? 'one_time_permanent',
342
- offerKey: options?.offerKey,
343
- obfuscatedAccountId: options?.obfuscatedAccountId,
344
- // Present only for a plan change. The store needs the token of
345
- // the subscription being replaced so it migrates the existing
346
- // purchase instead of creating a second one (FR-065).
347
- replaceStoreProductId: options?.replaceProductKey
348
- ? deps
349
- .snapshot()
350
- .purchases.find(
351
- p =>
352
- p.productKey === options.replaceProductKey &&
353
- p.state === 'purchased',
354
- )?.token
355
- : undefined,
356
- prorationMode: options?.prorationMode,
357
- }),
358
- // Covers opening the store surface only. Time the user spends
359
- // inside it is the store's, not ours (FR-035).
360
- resolved.timeouts.purchaseLaunchMs,
361
- () =>
362
- new PaymentError({
363
- code: 'PAYMENT_TIMEOUT',
364
- message: 'Opening the purchase surface timed out',
365
- operation: 'purchase',
366
- retryable: true,
367
- }),
368
- );
394
+ outcome = await launch(launchRequest(productKey, storeProductId, options));
369
395
  } catch {
370
396
  deps.emit({
371
397
  type: 'purchase_failed',
@@ -98,6 +98,9 @@ const DEFAULT_CAPABILITIES: ProviderCapabilities = {
98
98
  // lets a test complete a purchase while still blocking the real adapter
99
99
  // outside production (FR-014).
100
100
  createsRealTransactions: false,
101
+ // Its launch returns at once, so the engine's launch timeout applies and stays
102
+ // testable (FR-035).
103
+ purchaseOutcomeIncludesStoreTime: false,
101
104
  };
102
105
 
103
106
  let sequence = 0;