@mpgd/adapter-capacitor 0.16.3 → 0.18.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,170 @@
1
+ /**
2
+ * Upper bound on how long one store sheet counts as open (or possibly open)
3
+ * without a native `closed` fact. Past it the provider reports `closed` with
4
+ * reason `timeout`, so a lost native observation can pause the game, but
5
+ * never forever. It matches the purchase flow timeout of the OpenIAP Google
6
+ * core (15 minutes).
7
+ */
8
+ export const purchasePresentationTimeoutMs = 15 * 60_000;
9
+ /**
10
+ * Sheet facts by presentation id from the plugin's `purchasePresentation`
11
+ * events and the `presentation` field of its `purchase` answers, whichever
12
+ * arrives first; each call gets at most one `open` and one terminal fact.
13
+ * A `requested` answer is reported as `unknown`: the sheet may still come,
14
+ * so the provider keeps the call waiting for the native `open`, `closed`, or
15
+ * `not-started` instead of reading the answer as a sheet that never opened
16
+ * or inventing one that did. The native listener is attached with the first
17
+ * subscriber and removed with the last.
18
+ *
19
+ * Exported for the store plugins, which peer on this package. A plugin reads
20
+ * it through the module namespace, so with an adapter from before it the
21
+ * plugin declares no facts (and that adapter never asks for them).
22
+ */
23
+ export function createNativePurchasePresentation(native) {
24
+ const listeners = new Set();
25
+ const sheets = new Map();
26
+ let handle;
27
+ let generation = 0;
28
+ let support;
29
+ const report = (presentationId, state, reason) => {
30
+ const current = sheets.get(presentationId);
31
+ if (current === undefined || current === state || (state === 'unknown' && current === 'open')) {
32
+ return;
33
+ }
34
+ if (state === 'open' || state === 'unknown') {
35
+ sheets.set(presentationId, state);
36
+ }
37
+ else {
38
+ sheets.delete(presentationId);
39
+ }
40
+ const event = {
41
+ presentationId,
42
+ state,
43
+ ...(reason === undefined ? {} : { reason }),
44
+ };
45
+ for (const entry of [...listeners]) {
46
+ if (!listeners.has(entry)) {
47
+ continue;
48
+ }
49
+ // One failing listener must not keep a later one (the commerce
50
+ // provider) from its `closed` fact.
51
+ try {
52
+ entry.listener(event);
53
+ }
54
+ catch (error) {
55
+ console.warn('A purchase presentation listener failed.', error);
56
+ }
57
+ }
58
+ };
59
+ const onNativeFact = (event) => {
60
+ if (!isRecord(event) || typeof event.presentationId !== 'string') {
61
+ return;
62
+ }
63
+ const { state, reason } = event;
64
+ if (state === 'open' || state === 'closed' || state === 'not-started') {
65
+ report(event.presentationId, state, state === 'closed' && (reason === 'interrupted' || reason === 'timeout')
66
+ ? reason
67
+ : undefined);
68
+ }
69
+ };
70
+ const attach = () => {
71
+ if (handle !== undefined) {
72
+ return;
73
+ }
74
+ const attached = generation;
75
+ let added;
76
+ try {
77
+ added = native.addListener('purchasePresentation', (event) => {
78
+ if (attached === generation) {
79
+ onNativeFact(event);
80
+ }
81
+ });
82
+ }
83
+ catch {
84
+ return;
85
+ }
86
+ handle = added;
87
+ added.catch(() => {
88
+ if (handle === added) {
89
+ handle = undefined;
90
+ }
91
+ });
92
+ };
93
+ const detach = () => {
94
+ const current = handle;
95
+ if (current === undefined) {
96
+ return;
97
+ }
98
+ handle = undefined;
99
+ generation += 1;
100
+ current.then((resolved) => resolved.remove()).catch(() => undefined);
101
+ };
102
+ return {
103
+ purchasePresentation: {
104
+ subscribe(listener) {
105
+ const entry = { listener };
106
+ listeners.add(entry);
107
+ attach();
108
+ return () => {
109
+ if (listeners.delete(entry) && listeners.size === 0) {
110
+ detach();
111
+ }
112
+ };
113
+ },
114
+ },
115
+ followed() {
116
+ return listeners.size > 0;
117
+ },
118
+ supported() {
119
+ if (support !== undefined) {
120
+ return support;
121
+ }
122
+ const check = native.getPresentationSupport;
123
+ if (typeof check !== 'function') {
124
+ return Promise.resolve(false);
125
+ }
126
+ const attempt = Promise.resolve()
127
+ .then(() => check.call(native))
128
+ .then((answer) => isRecord(answer)
129
+ && typeof answer.purchasePresentation === 'number'
130
+ && answer.purchasePresentation >= 1, (error) => {
131
+ if (!isRecord(error) || error.code !== 'UNIMPLEMENTED') {
132
+ support = undefined;
133
+ }
134
+ return false;
135
+ });
136
+ support = attempt;
137
+ return attempt;
138
+ },
139
+ begin(presentationId) {
140
+ sheets.set(presentationId, 'requested');
141
+ },
142
+ answered(presentationId, answerOrError) {
143
+ const presentation = answeredPresentation(answerOrError);
144
+ if (presentation === 'open' || presentation === 'closed' || presentation === 'not-started') {
145
+ report(presentationId, presentation);
146
+ }
147
+ else if (presentation === 'requested') {
148
+ report(presentationId, 'unknown');
149
+ }
150
+ else if (sheets.get(presentationId) === 'requested') {
151
+ // No sheet state from native code that declared facts: nothing was
152
+ // seen opening, which the provider reads as not-started.
153
+ sheets.delete(presentationId);
154
+ }
155
+ },
156
+ };
157
+ }
158
+ /** The `presentation` of a `purchase` answer or of a rejection's `data`. */
159
+ function answeredPresentation(answer) {
160
+ if (!isRecord(answer)) {
161
+ return undefined;
162
+ }
163
+ if ('presentation' in answer) {
164
+ return answer.presentation;
165
+ }
166
+ return isRecord(answer.data) ? answer.data.presentation : undefined;
167
+ }
168
+ function isRecord(value) {
169
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
170
+ }
@@ -1,5 +1,5 @@
1
- import type { PlatformEvidenceEnvelope } from '@mpgd/platform';
2
- import { type CommerceAuthorityCapabilities, type CommerceCatalogCapabilities, type CommerceErrorCode, type CommerceLifecycleCapabilities, type CommerceLogicalProductId, type CommerceProvider, type CommercePurchaseCapabilities, type PurchaseKind, type StoreId, type StorePurchaseAccountBinding } from '@mpgd/platform/commerce';
1
+ import { type PlatformEvidenceEnvelope } from '@mpgd/platform';
2
+ import { type CommerceAuthorityCapabilities, type CommerceCatalogCapabilities, type CommerceErrorCode, type CommerceLifecycleCapabilities, type CommerceLogicalProductId, type CommerceProvider, type CommercePurchaseCapabilities, type PurchaseKind, type PurchaseSource, type StoreId, type StorePurchaseAccountBinding } from '@mpgd/platform/commerce';
3
3
  import { type StoreSdk, type StoreSdkErrorCodeMap, type StoreSdkPurchase } from './store-sdk.js';
4
4
  /** One catalog product sold through the store under `storeSku`. */
5
5
  export interface StoreCommerceProductMapping {
@@ -15,6 +15,41 @@ export interface StoreCommerceCapabilityOverrides {
15
15
  readonly catalog?: Partial<CommerceCatalogCapabilities>;
16
16
  readonly lifecycle?: Partial<CommerceLifecycleCapabilities>;
17
17
  }
18
+ /** What a purchase gate is asked about: one new purchase, before any store UI opens. */
19
+ export interface StorePurchaseGateRequest {
20
+ readonly store: StoreId;
21
+ readonly logicalId: CommerceLogicalProductId;
22
+ readonly storeSku: string;
23
+ readonly kind: PurchaseKind;
24
+ readonly source: PurchaseSource;
25
+ }
26
+ /**
27
+ * A purchase gate's answer. A refusal becomes a `failed` purchase result with
28
+ * `ITEM_UNAVAILABLE` and `storeCode` (default `PURCHASES_PAUSED`), returned as
29
+ * data so the reason survives the bridge; `retryable` defaults to false.
30
+ */
31
+ export type StorePurchaseGateDecision = {
32
+ readonly allowed: true;
33
+ } | {
34
+ readonly allowed: false;
35
+ readonly storeCode?: string;
36
+ readonly message?: string;
37
+ readonly retryable?: boolean;
38
+ };
39
+ /**
40
+ * The game's sales switch: asked before a NEW purchase opens the store UI,
41
+ * after the product, availability, and kind checks and before the account
42
+ * binding. Recovery, restore, finishing, events, and `getAvailablePurchases`
43
+ * never consult it, so purchases already paid for keep reconciling while new
44
+ * sales are refused. A gate that throws refuses the purchase (fail closed).
45
+ */
46
+ export type StorePurchaseGate = (request: StorePurchaseGateRequest) => StorePurchaseGateDecision | Promise<StorePurchaseGateDecision>;
47
+ /** `storeCode` of a purchase the gate refused without naming its own. */
48
+ export declare const purchasesPausedStoreCode: 'PURCHASES_PAUSED';
49
+ /** `storeCode` of a purchase refused because the gate threw or answered nonsense. */
50
+ export declare const purchaseGateFailedStoreCode: 'PURCHASE_GATE_FAILED';
51
+ /** `storeCode` of a purchase refused because the account binding lookup threw. */
52
+ export declare const accountBindingLookupFailedStoreCode: 'ACCOUNT_BINDING_LOOKUP_FAILED';
18
53
  export interface CreateStoreCommerceProviderInput {
19
54
  readonly sdk: StoreSdk;
20
55
  /** Defaults to `sdk.store`. */
@@ -27,23 +62,51 @@ export interface CreateStoreCommerceProviderInput {
27
62
  * and SKU; a store provider replaces it to match its backend verifier.
28
63
  */
29
64
  readonly evidencePayload?: (purchase: StoreSdkPurchase) => PlatformEvidenceEnvelope['payload'];
30
- /** Resolves the player's store account binding before a purchase opens. */
65
+ /**
66
+ * Resolves the player's store account binding before a purchase opens. A
67
+ * failure (thrown, or no binding while `authority.accountBinding` is
68
+ * `required`) is answered as a `failed` purchase result, not thrown, so its
69
+ * `storeCode` survives the bridge.
70
+ */
31
71
  readonly accountBinding?: () => Promise<StorePurchaseAccountBinding | undefined>;
72
+ /** Optional sales switch for new purchases; see `StorePurchaseGate`. */
73
+ readonly purchaseGate?: StorePurchaseGate;
32
74
  readonly capabilities?: StoreCommerceCapabilityOverrides;
33
75
  readonly authority: CommerceAuthorityCapabilities;
34
76
  /** Per-store error codes merged over `defaultStoreSdkErrorCodeMap`. */
35
77
  readonly mapError?: StoreSdkErrorCodeMap;
36
78
  }
37
79
  export interface StoreCommerceProvider extends CommerceProvider {
38
- /** Detaches the SDK listeners; buffered events are dropped. */
80
+ /**
81
+ * Detaches the SDK listeners; buffered events are dropped. A store sheet
82
+ * still open gets its `closed` fact (reason `disposed`) first.
83
+ */
39
84
  dispose(): void;
40
85
  }
41
86
  /**
87
+ * Upper bound on one purchase gate answer. The gate runs while the gateway
88
+ * holds the purchase's external activity, so a gate that never settles would
89
+ * keep the game paused; past this bound the purchase is refused
90
+ * (`PURCHASE_GATE_FAILED`, retryable) and a late answer is ignored.
91
+ */
92
+ export declare const purchaseGateTimeoutMs = 3000;
93
+ /**
42
94
  * `CommerceProvider` over an in-process `StoreSdk`. The provider maps logical
43
95
  * ids to store SKUs both ways, composes `CommerceCapabilities` from the SDK
44
96
  * declaration, builds `StorePurchase` evidence, buffers SDK events until the
45
97
  * first subscriber, and normalizes every SDK failure into a `CommerceError`.
46
98
  * It never grants and never sets `settlement`; the backend ledger decides.
99
+ *
100
+ * Over an SDK that declares `purchasePresentation`, the provider offers
101
+ * `presentation` and answers every `requestPurchase` call with facts keyed
102
+ * by its idempotency key: `not-started`, delivered before the call settles,
103
+ * for any call that opened no sheet (a purchase gate or account binding
104
+ * refusal, an unavailable store, a throw, or the SDK's own `not-started`);
105
+ * otherwise `open` and exactly one `closed` when the SDK reports the sheet
106
+ * gone, or after `purchasePresentationTimeoutMs` (reason `timeout`), or on
107
+ * `dispose` (reason `disposed`). A result, a pending purchase, or a purchase
108
+ * event never closes a sheet. An SDK without the declaration leaves the
109
+ * provider without `presentation`, as before.
47
110
  */
48
111
  export declare function createStoreCommerceProvider(input: CreateStoreCommerceProviderInput): StoreCommerceProvider;
49
112
  /**
@@ -1,6 +1,21 @@
1
+ import { createPurchasePresentationChannel, isPurchasePresentationReason, } from '@mpgd/platform';
1
2
  import { assertFinishTransactionInput, assertFinishTransactionResult, assertRequestPurchaseSupported, assertStoreProduct, assertStorePurchase, CommerceError, createCommerceEventBuffer, } from '@mpgd/platform/commerce';
3
+ import { purchasePresentationTimeoutMs, } from './purchase-presentation.js';
2
4
  import { defaultStoreSdkErrorCodeMap, normalizeStoreSdkError, } from './store-sdk.js';
5
+ /** `storeCode` of a purchase the gate refused without naming its own. */
6
+ export const purchasesPausedStoreCode = 'PURCHASES_PAUSED';
7
+ /** `storeCode` of a purchase refused because the gate threw or answered nonsense. */
8
+ export const purchaseGateFailedStoreCode = 'PURCHASE_GATE_FAILED';
9
+ /** `storeCode` of a purchase refused because the account binding lookup threw. */
10
+ export const accountBindingLookupFailedStoreCode = 'ACCOUNT_BINDING_LOOKUP_FAILED';
3
11
  const availabilityTimeoutMs = 3_000;
12
+ /**
13
+ * Upper bound on one purchase gate answer. The gate runs while the gateway
14
+ * holds the purchase's external activity, so a gate that never settles would
15
+ * keep the game paused; past this bound the purchase is refused
16
+ * (`PURCHASE_GATE_FAILED`, retryable) and a late answer is ignored.
17
+ */
18
+ export const purchaseGateTimeoutMs = 3_000;
4
19
  const availabilitySet = new Set([
5
20
  'unsupported',
6
21
  'configuration-required',
@@ -14,6 +29,17 @@ const availabilitySet = new Set([
14
29
  * declaration, builds `StorePurchase` evidence, buffers SDK events until the
15
30
  * first subscriber, and normalizes every SDK failure into a `CommerceError`.
16
31
  * It never grants and never sets `settlement`; the backend ledger decides.
32
+ *
33
+ * Over an SDK that declares `purchasePresentation`, the provider offers
34
+ * `presentation` and answers every `requestPurchase` call with facts keyed
35
+ * by its idempotency key: `not-started`, delivered before the call settles,
36
+ * for any call that opened no sheet (a purchase gate or account binding
37
+ * refusal, an unavailable store, a throw, or the SDK's own `not-started`);
38
+ * otherwise `open` and exactly one `closed` when the SDK reports the sheet
39
+ * gone, or after `purchasePresentationTimeoutMs` (reason `timeout`), or on
40
+ * `dispose` (reason `disposed`). A result, a pending purchase, or a purchase
41
+ * event never closes a sheet. An SDK without the declaration leaves the
42
+ * provider without `presentation`, as before.
17
43
  */
18
44
  export function createStoreCommerceProvider(input) {
19
45
  const { sdk } = input;
@@ -134,6 +160,77 @@ export function createStoreCommerceProvider(input) {
134
160
  });
135
161
  }),
136
162
  ];
163
+ // Presentation: SDK facts by presentation id, published by idempotency key.
164
+ const sdkPresentation = sdk.purchasePresentation;
165
+ const presentation = sdkPresentation === undefined
166
+ ? undefined
167
+ : createPurchasePresentationChannel({
168
+ onListenerError: (error) => {
169
+ // A failing listener must not keep a `closed` fact from the others.
170
+ console.warn('A purchase presentation listener failed.', error);
171
+ },
172
+ });
173
+ const flights = new Map();
174
+ let disposed = false;
175
+ const endFlight = (flight, state, reason) => {
176
+ if (flights.get(flight.presentationId) !== flight) {
177
+ return;
178
+ }
179
+ flights.delete(flight.presentationId);
180
+ if (flight.timer !== undefined) {
181
+ clearTimeout(flight.timer);
182
+ }
183
+ // A sheet that opened can only close; `not-started` after `open` would
184
+ // tell a consumer the sheet never showed.
185
+ const fact = flight.open ? 'closed' : state;
186
+ presentation?.publish({
187
+ idempotencyKey: flight.idempotencyKey,
188
+ state: fact,
189
+ ...(reason === undefined || fact !== 'closed' ? {} : { reason }),
190
+ });
191
+ };
192
+ const onPresentationFact = (event) => {
193
+ if (!isRecord(event) || typeof event.presentationId !== 'string') {
194
+ return;
195
+ }
196
+ const flight = flights.get(event.presentationId);
197
+ if (flight === undefined) {
198
+ // Another call's, an already ended call's, or an earlier session's fact.
199
+ return;
200
+ }
201
+ const armSafetyTimeout = () => {
202
+ flight.timer ??= setTimeout(() => endFlight(flight, 'closed', 'timeout'), purchasePresentationTimeoutMs);
203
+ };
204
+ switch (event.state) {
205
+ case 'open':
206
+ if (!flight.open) {
207
+ flight.open = true;
208
+ presentation?.publish({ idempotencyKey: flight.idempotencyKey, state: 'open' });
209
+ armSafetyTimeout();
210
+ }
211
+ return;
212
+ case 'unknown':
213
+ // The sheet may still open: the call's answer does not end it.
214
+ if (!flight.open && !flight.pending) {
215
+ flight.pending = true;
216
+ presentation?.publish({ idempotencyKey: flight.idempotencyKey, state: 'unknown' });
217
+ armSafetyTimeout();
218
+ }
219
+ return;
220
+ case 'closed':
221
+ endFlight(flight, 'closed', isPurchasePresentationReason(event.reason) ? event.reason : undefined);
222
+ return;
223
+ case 'not-started':
224
+ endFlight(flight, 'not-started');
225
+ return;
226
+ default:
227
+ // An unknown fact proves nothing; the safety timeout still bounds the sheet.
228
+ return;
229
+ }
230
+ };
231
+ if (sdkPresentation !== undefined) {
232
+ detachers.push(sdkPresentation.subscribe(onPresentationFact));
233
+ }
137
234
  const getAvailability = sdk.getAvailability;
138
235
  const readAvailability = async () => {
139
236
  if (getAvailability === undefined) {
@@ -172,10 +269,91 @@ export function createStoreCommerceProvider(input) {
172
269
  authority: { ...input.authority },
173
270
  availability: await readAvailability(),
174
271
  });
272
+ const requestStorePurchase = async (request, presentationId) => {
273
+ const mapping = byLogicalId.get(request.logicalId);
274
+ if (mapping === undefined) {
275
+ throw new CommerceError('ITEM_UNAVAILABLE', {
276
+ message: `Store ${store} does not sell ${request.logicalId}.`,
277
+ });
278
+ }
279
+ const capabilities = await getCapabilities();
280
+ assertAvailable(capabilities.availability, store);
281
+ assertRequestPurchaseSupported(capabilities, request, mapping.kind);
282
+ // Pre-launch refusals below are data, not throws: a `failed` result
283
+ // keeps its `storeCode` across the bridge, a thrown error does not.
284
+ if (input.purchaseGate !== undefined) {
285
+ const refusal = await askPurchaseGate(input.purchaseGate, {
286
+ store,
287
+ logicalId: mapping.logicalId,
288
+ storeSku: mapping.storeSku,
289
+ kind: mapping.kind,
290
+ source: request.source,
291
+ });
292
+ if (refusal !== undefined) {
293
+ return { outcome: 'failed', error: refusal };
294
+ }
295
+ }
296
+ let accountBinding;
297
+ try {
298
+ accountBinding = await input.accountBinding?.();
299
+ }
300
+ catch (error) {
301
+ return { outcome: 'failed', error: accountBindingFailure(error, store) };
302
+ }
303
+ if (input.authority.accountBinding === 'required' && accountBinding === undefined) {
304
+ return {
305
+ outcome: 'failed',
306
+ error: new CommerceError('ACCOUNT_BINDING_REQUIRED', {
307
+ message: `Store ${store} requires an account binding before a purchase.`,
308
+ }).toCommerceErrorInfo(),
309
+ };
310
+ }
311
+ const offerId = request.offerId ?? mapping.offerId;
312
+ if (disposed) {
313
+ // Its listeners are gone: a sheet opened now could never be reported.
314
+ throw new CommerceError('NOT_PREPARED', {
315
+ message: `Store ${store} was disposed before the purchase opened.`,
316
+ });
317
+ }
318
+ const flight = presentationId === undefined ? undefined : flights.get(presentationId);
319
+ if (flight !== undefined) {
320
+ flight.launched = true;
321
+ }
322
+ let result;
323
+ try {
324
+ result = await sdk.requestPurchase({
325
+ sku: mapping.storeSku,
326
+ ...(offerId === undefined ? {} : { offerId }),
327
+ ...(request.quantity === undefined ? {} : { quantity: request.quantity }),
328
+ ...(accountBinding === undefined ? {} : { accountBinding }),
329
+ ...(presentationId === undefined ? {} : { presentationId }),
330
+ });
331
+ }
332
+ catch (error) {
333
+ return failureOutcome(undefined, normalize(error).toCommerceErrorInfo());
334
+ }
335
+ if (!isRecord(result)) {
336
+ throw invalidRequestResult(store);
337
+ }
338
+ switch (result.outcome) {
339
+ case 'launched':
340
+ return { outcome: 'launched' };
341
+ case 'purchased':
342
+ return { outcome: 'purchased', purchase: toStorePurchase(result.purchase) };
343
+ case 'cancelled':
344
+ case 'failed':
345
+ return failureOutcome(result.outcome, normalize(result.error).toCommerceErrorInfo());
346
+ default:
347
+ throw invalidRequestResult(store);
348
+ }
349
+ };
175
350
  const finishTransaction = sdk.finishTransaction;
176
351
  const restorePurchases = sdk.restorePurchases;
177
352
  const provider = {
178
353
  store,
354
+ ...(presentation === undefined ? {} : { presentation: presentation.presentation }),
355
+ // `retry` is forwarded to the SDK below.
356
+ ...(finishTransaction === undefined ? {} : { acceptsFinishRetry: true }),
179
357
  getCapabilities,
180
358
  async fetchProducts(request = {}) {
181
359
  const type = request.type ?? 'all';
@@ -236,47 +414,41 @@ export function createStoreCommerceProvider(input) {
236
414
  return products;
237
415
  },
238
416
  async requestPurchase(request) {
239
- const mapping = byLogicalId.get(request.logicalId);
240
- if (mapping === undefined) {
241
- throw new CommerceError('ITEM_UNAVAILABLE', {
242
- message: `Store ${store} does not sell ${request.logicalId}.`,
243
- });
417
+ if (presentation === undefined) {
418
+ return requestStorePurchase(request, undefined);
244
419
  }
245
- const capabilities = await getCapabilities();
246
- assertAvailable(capabilities.availability, store);
247
- assertRequestPurchaseSupported(capabilities, request, mapping.kind);
248
- const accountBinding = await input.accountBinding?.();
249
- if (input.authority.accountBinding === 'required' && accountBinding === undefined) {
250
- throw new CommerceError('ACCOUNT_BINDING_REQUIRED', {
251
- message: `Store ${store} requires an account binding before a purchase.`,
252
- });
420
+ if ([...flights.values()].some((live) => live.idempotencyKey === request.idempotencyKey)) {
421
+ // Facts are keyed by idempotency key: a second sheet for a key whose
422
+ // sheet is still in flight could not be told apart from the first,
423
+ // and its end would end the first one's pause. Answered as data and
424
+ // without a fact; the first call's facts stay the key's truth.
425
+ return {
426
+ outcome: 'failed',
427
+ error: new CommerceError('IN_PROGRESS', {
428
+ message: `A purchase for ${request.idempotencyKey} is still in flight.`,
429
+ }).toCommerceErrorInfo(),
430
+ };
253
431
  }
254
- const offerId = request.offerId ?? mapping.offerId;
255
- let result;
432
+ const flight = {
433
+ idempotencyKey: request.idempotencyKey,
434
+ presentationId: crypto.randomUUID(),
435
+ open: false,
436
+ pending: false,
437
+ launched: false,
438
+ };
439
+ flights.set(flight.presentationId, flight);
256
440
  try {
257
- result = await sdk.requestPurchase({
258
- sku: mapping.storeSku,
259
- ...(offerId === undefined ? {} : { offerId }),
260
- ...(request.quantity === undefined ? {} : { quantity: request.quantity }),
261
- ...(accountBinding === undefined ? {} : { accountBinding }),
262
- });
441
+ return await requestStorePurchase(request, flight.presentationId);
263
442
  }
264
- catch (error) {
265
- return failureOutcome(undefined, normalize(error).toCommerceErrorInfo());
266
- }
267
- if (!isRecord(result)) {
268
- throw invalidRequestResult(store);
269
- }
270
- switch (result.outcome) {
271
- case 'launched':
272
- return { outcome: 'launched' };
273
- case 'purchased':
274
- return { outcome: 'purchased', purchase: toStorePurchase(result.purchase) };
275
- case 'cancelled':
276
- case 'failed':
277
- return failureOutcome(result.outcome, normalize(result.error).toCommerceErrorInfo());
278
- default:
279
- throw invalidRequestResult(store);
443
+ finally {
444
+ // Settled without an `open` fact: no sheet opened. Published before
445
+ // the caller sees the answer, so a consumer that frees a retryable
446
+ // refusal's key (the reconciler) reads it with the result. A call
447
+ // whose SDK reported `unknown` keeps waiting for the native verdict
448
+ // (bounded by the safety timeout, which reports `closed`).
449
+ if (!flight.open && !flight.pending) {
450
+ endFlight(flight, 'not-started');
451
+ }
280
452
  }
281
453
  },
282
454
  async getAvailablePurchases(request = {}) {
@@ -322,6 +494,7 @@ export function createStoreCommerceProvider(input) {
322
494
  transactionId: finish.transactionId,
323
495
  isConsumable: finish.isConsumable,
324
496
  ledgerEntryId: finish.ledgerEntryId,
497
+ ...(finish.retry === true ? { retry: true } : {}),
325
498
  });
326
499
  }
327
500
  catch (error) {
@@ -361,6 +534,15 @@ export function createStoreCommerceProvider(input) {
361
534
  for (const detach of detachers.splice(0, detachers.length)) {
362
535
  detach();
363
536
  }
537
+ // No SDK fact arrives any more, and the listeners go below: every call
538
+ // in flight gets its terminal fact now. A sheet that is (or may be)
539
+ // up is reported closed; a call still before its sheet is not-started,
540
+ // and it will not open one.
541
+ disposed = true;
542
+ for (const flight of [...flights.values()]) {
543
+ endFlight(flight, flight.open || flight.pending || flight.launched ? 'closed' : 'not-started', 'disposed');
544
+ }
545
+ presentation?.clear();
364
546
  listeners.clear();
365
547
  buffer.drain(() => undefined);
366
548
  },
@@ -387,6 +569,88 @@ function defaultEvidencePayload(purchase) {
387
569
  : { originalTransactionId: purchase.originalTransactionId }),
388
570
  };
389
571
  }
572
+ /**
573
+ * Codes a pre-launch hook (the purchase gate or the account binding) must not
574
+ * report: they claim a store sheet was shown or may hold a purchase, and
575
+ * nothing was launched. A hook that throws one is reported under the hook's
576
+ * own refusal code instead.
577
+ */
578
+ function misleadsBeforeLaunch(code) {
579
+ return recoverableErrorCodes.has(code) || code === 'USER_CANCELLED';
580
+ }
581
+ /**
582
+ * Asks the gate about one new purchase, bounded by `purchaseGateTimeoutMs`;
583
+ * undefined means allowed. A refusal
584
+ * (`allowed: false`) is the gate's definitive answer: `PURCHASES_PAUSED` or
585
+ * its own code, retryable only when it says so. A gate that could not answer
586
+ * (it threw, timed out, or returned anything malformed) is `PURCHASE_GATE_FAILED`, and
587
+ * always retryable: the sales flag is unknown, not closed, so asking again
588
+ * may succeed. A thrown `CommerceError` keeps its code unless the code
589
+ * claims a launched or cancelled sheet.
590
+ */
591
+ async function askPurchaseGate(gate, request) {
592
+ const gateFailed = (message, cause) => new CommerceError('ITEM_UNAVAILABLE', {
593
+ message,
594
+ storeCode: purchaseGateFailedStoreCode,
595
+ retryable: true,
596
+ ...(cause === undefined ? {} : { cause }),
597
+ }).toCommerceErrorInfo();
598
+ let decision;
599
+ let timer;
600
+ try {
601
+ decision = await Promise.race([
602
+ Promise.resolve().then(() => gate(request)),
603
+ new Promise((_resolve, reject) => {
604
+ timer = setTimeout(() => reject(new Error(`The purchase gate did not answer within ${purchaseGateTimeoutMs} ms.`)), purchaseGateTimeoutMs);
605
+ }),
606
+ ]);
607
+ }
608
+ catch (error) {
609
+ if (error instanceof CommerceError && !misleadsBeforeLaunch(error.code)) {
610
+ return error.toCommerceErrorInfo();
611
+ }
612
+ return gateFailed(`The purchase gate of ${request.store} failed; ${request.logicalId} is not sold now.`, error);
613
+ }
614
+ finally {
615
+ if (timer !== undefined) {
616
+ clearTimeout(timer);
617
+ }
618
+ }
619
+ if (isRecord(decision) && decision.allowed === true) {
620
+ return undefined;
621
+ }
622
+ if (!isRecord(decision) || decision.allowed !== false) {
623
+ return gateFailed(`The purchase gate of ${request.store} returned an invalid decision.`);
624
+ }
625
+ const storeCode = typeof decision.storeCode === 'string' && decision.storeCode.length > 0
626
+ ? decision.storeCode
627
+ : purchasesPausedStoreCode;
628
+ return new CommerceError('ITEM_UNAVAILABLE', {
629
+ message: typeof decision.message === 'string' && decision.message.length > 0
630
+ ? decision.message
631
+ : `New purchases of ${request.logicalId} are paused.`,
632
+ storeCode,
633
+ retryable: decision.retryable === true,
634
+ }).toCommerceErrorInfo();
635
+ }
636
+ /**
637
+ * A failed account binding lookup. A `CommerceError` keeps its code (a store
638
+ * provider names its own lookup failure) unless the code claims a launched or
639
+ * cancelled sheet; anything else is `ACCOUNT_BINDING_REQUIRED` with
640
+ * `ACCOUNT_BINDING_LOOKUP_FAILED`, retryable, so every store reports the
641
+ * same reason.
642
+ */
643
+ function accountBindingFailure(error, store) {
644
+ if (error instanceof CommerceError && !misleadsBeforeLaunch(error.code)) {
645
+ return error.toCommerceErrorInfo();
646
+ }
647
+ return new CommerceError('ACCOUNT_BINDING_REQUIRED', {
648
+ message: `Store ${store} could not resolve the account binding.`,
649
+ storeCode: accountBindingLookupFailedStoreCode,
650
+ retryable: true,
651
+ cause: error,
652
+ }).toCommerceErrorInfo();
653
+ }
390
654
  function assertAvailable(availability, store) {
391
655
  switch (availability) {
392
656
  case 'available':