@mpgd/adapter-capacitor 0.17.0 → 0.19.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,4 +1,4 @@
1
- import type { PlatformEvidenceEnvelope } from '@mpgd/platform';
1
+ import { type PlatformEvidenceEnvelope } from '@mpgd/platform';
2
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`. */
@@ -77,7 +77,10 @@ export interface CreateStoreCommerceProviderInput {
77
77
  readonly mapError?: StoreSdkErrorCodeMap;
78
78
  }
79
79
  export interface StoreCommerceProvider extends CommerceProvider {
80
- /** 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
+ */
81
84
  dispose(): void;
82
85
  }
83
86
  /**
@@ -93,6 +96,17 @@ export declare const purchaseGateTimeoutMs = 3000;
93
96
  * declaration, builds `StorePurchase` evidence, buffers SDK events until the
94
97
  * first subscriber, and normalizes every SDK failure into a `CommerceError`.
95
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.
96
110
  */
97
111
  export declare function createStoreCommerceProvider(input: CreateStoreCommerceProviderInput): StoreCommerceProvider;
98
112
  /**
@@ -1,4 +1,6 @@
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';
3
5
  /** `storeCode` of a purchase the gate refused without naming its own. */
4
6
  export const purchasesPausedStoreCode = 'PURCHASES_PAUSED';
@@ -27,6 +29,17 @@ const availabilitySet = new Set([
27
29
  * declaration, builds `StorePurchase` evidence, buffers SDK events until the
28
30
  * first subscriber, and normalizes every SDK failure into a `CommerceError`.
29
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.
30
43
  */
31
44
  export function createStoreCommerceProvider(input) {
32
45
  const { sdk } = input;
@@ -147,6 +160,77 @@ export function createStoreCommerceProvider(input) {
147
160
  });
148
161
  }),
149
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
+ }
150
234
  const getAvailability = sdk.getAvailability;
151
235
  const readAvailability = async () => {
152
236
  if (getAvailability === undefined) {
@@ -185,12 +269,95 @@ export function createStoreCommerceProvider(input) {
185
269
  authority: { ...input.authority },
186
270
  availability: await readAvailability(),
187
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
+ };
188
350
  const finishTransaction = sdk.finishTransaction;
189
351
  const restorePurchases = sdk.restorePurchases;
352
+ /** The SDK reads `revoked` and has a finish to take it. */
353
+ const revokedFinish = finishTransaction !== undefined && sdk.acceptsRevokedFinish === true;
190
354
  const provider = {
191
355
  store,
356
+ ...(presentation === undefined ? {} : { presentation: presentation.presentation }),
192
357
  // `retry` is forwarded to the SDK below.
193
358
  ...(finishTransaction === undefined ? {} : { acceptsFinishRetry: true }),
359
+ // `revoked` is forwarded only to an SDK that reads it.
360
+ ...(revokedFinish ? { acceptsRevokedFinish: true } : {}),
194
361
  getCapabilities,
195
362
  async fetchProducts(request = {}) {
196
363
  const type = request.type ?? 'all';
@@ -251,70 +418,41 @@ export function createStoreCommerceProvider(input) {
251
418
  return products;
252
419
  },
253
420
  async requestPurchase(request) {
254
- const mapping = byLogicalId.get(request.logicalId);
255
- if (mapping === undefined) {
256
- throw new CommerceError('ITEM_UNAVAILABLE', {
257
- message: `Store ${store} does not sell ${request.logicalId}.`,
258
- });
259
- }
260
- const capabilities = await getCapabilities();
261
- assertAvailable(capabilities.availability, store);
262
- assertRequestPurchaseSupported(capabilities, request, mapping.kind);
263
- // Pre-launch refusals below are data, not throws: a `failed` result
264
- // keeps its `storeCode` across the bridge, a thrown error does not.
265
- if (input.purchaseGate !== undefined) {
266
- const refusal = await askPurchaseGate(input.purchaseGate, {
267
- store,
268
- logicalId: mapping.logicalId,
269
- storeSku: mapping.storeSku,
270
- kind: mapping.kind,
271
- source: request.source,
272
- });
273
- if (refusal !== undefined) {
274
- return { outcome: 'failed', error: refusal };
275
- }
276
- }
277
- let accountBinding;
278
- try {
279
- accountBinding = await input.accountBinding?.();
280
- }
281
- catch (error) {
282
- return { outcome: 'failed', error: accountBindingFailure(error, store) };
421
+ if (presentation === undefined) {
422
+ return requestStorePurchase(request, undefined);
283
423
  }
284
- if (input.authority.accountBinding === 'required' && accountBinding === undefined) {
424
+ if ([...flights.values()].some((live) => live.idempotencyKey === request.idempotencyKey)) {
425
+ // Facts are keyed by idempotency key: a second sheet for a key whose
426
+ // sheet is still in flight could not be told apart from the first,
427
+ // and its end would end the first one's pause. Answered as data and
428
+ // without a fact; the first call's facts stay the key's truth.
285
429
  return {
286
430
  outcome: 'failed',
287
- error: new CommerceError('ACCOUNT_BINDING_REQUIRED', {
288
- message: `Store ${store} requires an account binding before a purchase.`,
431
+ error: new CommerceError('IN_PROGRESS', {
432
+ message: `A purchase for ${request.idempotencyKey} is still in flight.`,
289
433
  }).toCommerceErrorInfo(),
290
434
  };
291
435
  }
292
- const offerId = request.offerId ?? mapping.offerId;
293
- let result;
436
+ const flight = {
437
+ idempotencyKey: request.idempotencyKey,
438
+ presentationId: crypto.randomUUID(),
439
+ open: false,
440
+ pending: false,
441
+ launched: false,
442
+ };
443
+ flights.set(flight.presentationId, flight);
294
444
  try {
295
- result = await sdk.requestPurchase({
296
- sku: mapping.storeSku,
297
- ...(offerId === undefined ? {} : { offerId }),
298
- ...(request.quantity === undefined ? {} : { quantity: request.quantity }),
299
- ...(accountBinding === undefined ? {} : { accountBinding }),
300
- });
445
+ return await requestStorePurchase(request, flight.presentationId);
301
446
  }
302
- catch (error) {
303
- return failureOutcome(undefined, normalize(error).toCommerceErrorInfo());
304
- }
305
- if (!isRecord(result)) {
306
- throw invalidRequestResult(store);
307
- }
308
- switch (result.outcome) {
309
- case 'launched':
310
- return { outcome: 'launched' };
311
- case 'purchased':
312
- return { outcome: 'purchased', purchase: toStorePurchase(result.purchase) };
313
- case 'cancelled':
314
- case 'failed':
315
- return failureOutcome(result.outcome, normalize(result.error).toCommerceErrorInfo());
316
- default:
317
- throw invalidRequestResult(store);
447
+ finally {
448
+ // Settled without an `open` fact: no sheet opened. Published before
449
+ // the caller sees the answer, so a consumer that frees a retryable
450
+ // refusal's key (the reconciler) reads it with the result. A call
451
+ // whose SDK reported `unknown` keeps waiting for the native verdict
452
+ // (bounded by the safety timeout, which reports `closed`).
453
+ if (!flight.open && !flight.pending) {
454
+ endFlight(flight, 'not-started');
455
+ }
318
456
  }
319
457
  },
320
458
  async getAvailablePurchases(request = {}) {
@@ -342,7 +480,14 @@ export function createStoreCommerceProvider(input) {
342
480
  const purchase = toStorePurchase(candidate);
343
481
  // A revoked purchase is not recoverable, and a pending one is listed
344
482
  // only on request; both still reach the reconciler through events.
345
- if (purchase.state === 'revoked' || (purchase.state === 'pending' && !includePending)) {
483
+ // An SDK that finishes revoked transactions keeps its unfinished
484
+ // revoked consumables listed (the only kind a revoked finish takes):
485
+ // the reconciler finishes them once the backend answered for them,
486
+ // and verifies or grants none of them. A revoked purchase of another
487
+ // kind, or of a SKU the catalog does not map, stays filtered.
488
+ if ((purchase.state === 'revoked'
489
+ && !(revokedFinish && bySku.get(candidate.sku)?.kind === 'consumable'))
490
+ || (purchase.state === 'pending' && !includePending)) {
346
491
  continue;
347
492
  }
348
493
  purchases.push(purchase);
@@ -354,14 +499,26 @@ export function createStoreCommerceProvider(input) {
354
499
  // Finishing is authorized by a ledger entry only; the id is validated
355
500
  // before the store hears about it.
356
501
  const finish = assertFinishTransactionInput(request);
502
+ if (finish.revoked === true && !revokedFinish) {
503
+ // Never an ordinary finish in its place: the SDK cannot tell the
504
+ // two apart, and this one would run without a grant.
505
+ return { finished: false };
506
+ }
357
507
  let reported;
358
508
  try {
359
- reported = await finishTransaction.call(sdk, {
360
- transactionId: finish.transactionId,
361
- isConsumable: finish.isConsumable,
362
- ledgerEntryId: finish.ledgerEntryId,
363
- ...(finish.retry === true ? { retry: true } : {}),
364
- });
509
+ reported = await finishTransaction.call(sdk, finish.revoked === true
510
+ ? {
511
+ transactionId: finish.transactionId,
512
+ isConsumable: true,
513
+ revoked: true,
514
+ ...(finish.ledgerEntryId === undefined ? {} : { ledgerEntryId: finish.ledgerEntryId }),
515
+ }
516
+ : {
517
+ transactionId: finish.transactionId,
518
+ isConsumable: finish.isConsumable,
519
+ ledgerEntryId: finish.ledgerEntryId,
520
+ ...(finish.retry === true ? { retry: true } : {}),
521
+ });
365
522
  }
366
523
  catch (error) {
367
524
  throw normalize(error);
@@ -400,6 +557,15 @@ export function createStoreCommerceProvider(input) {
400
557
  for (const detach of detachers.splice(0, detachers.length)) {
401
558
  detach();
402
559
  }
560
+ // No SDK fact arrives any more, and the listeners go below: every call
561
+ // in flight gets its terminal fact now. A sheet that is (or may be)
562
+ // up is reported closed; a call still before its sheet is not-started,
563
+ // and it will not open one.
564
+ disposed = true;
565
+ for (const flight of [...flights.values()]) {
566
+ endFlight(flight, flight.open || flight.pending || flight.launched ? 'closed' : 'not-started', 'disposed');
567
+ }
568
+ presentation?.clear();
403
569
  listeners.clear();
404
570
  buffer.drain(() => undefined);
405
571
  },
@@ -1,4 +1,4 @@
1
- import type { PlatformProviderAvailability } from '@mpgd/platform';
1
+ import type { PlatformProviderAvailability, PlatformPurchasePresentationReason } from '@mpgd/platform';
2
2
  import { CommerceError, type CommerceErrorCode, type CommerceFinishOwner, type CommercePurchaseCapabilities, type FetchProductsType, type PurchaseKind, type StoreId, type StoreOffer, type StorePrice, type StorePurchaseAccountBinding, type StorePurchaseState } from '@mpgd/platform/commerce';
3
3
  /**
4
4
  * In-process surface a store plugin exposes to `createStoreCommerceProvider`.
@@ -21,11 +21,54 @@ export interface StoreSdk {
21
21
  getAvailablePurchases(input: StoreSdkGetAvailablePurchasesInput): Promise<readonly StoreSdkPurchase[]>;
22
22
  /** Present when `capabilities.finish` is 'client' or 'host'. Called only after a ledger grant. */
23
23
  finishTransaction?(input: StoreSdkFinishTransactionInput): Promise<StoreSdkFinishTransactionResult>;
24
+ /**
25
+ * `finishTransaction` reads `revoked`: it finishes a transaction it still
26
+ * lists as revoked, and only that. `createStoreCommerceProvider` then
27
+ * declares `CommerceProvider.acceptsRevokedFinish` and keeps the revoked
28
+ * consumables `getAvailablePurchases` reports (still unfinished in the
29
+ * store) in its list, so the reconciler can finish them on a later launch; an SDK
30
+ * without it never receives the field, so a store that would read the call
31
+ * as an ordinary finish (a consume) is never asked.
32
+ */
33
+ readonly acceptsRevokedFinish?: boolean;
24
34
  /** Asks the store to resynchronize, for example `AppStore.sync`. */
25
35
  restorePurchases?(): Promise<void>;
26
36
  addListener<TEvent extends StoreSdkEventName>(eventName: TEvent, listener: (event: StoreSdkEventOf<TEvent>) => void): () => void;
27
37
  /** Current readiness; omitted means the SDK is in-process and ready. */
28
38
  getAvailability?(): Promise<PlatformProviderAvailability>;
39
+ /**
40
+ * Native facts about the store's purchase sheet. Present only when the SDK
41
+ * (and the native code under it) reports them for every `requestPurchase`
42
+ * that carries a `presentationId`; `createStoreCommerceProvider` then
43
+ * offers `CommerceProvider.presentation`. An SDK without it keeps the
44
+ * provider without presentation, as before.
45
+ */
46
+ readonly purchasePresentation?: StoreSdkPurchasePresentation;
47
+ }
48
+ /**
49
+ * The purchase sheet facts of one SDK. For a `requestPurchase` call with a
50
+ * `presentationId` the SDK reports, by that id: `open` once the store sheet
51
+ * is up, then exactly one `closed` when it is gone (which may come after the
52
+ * call settled), or `not-started` when no sheet opened. A fact the call
53
+ * settles without (the sheet never opened) means `not-started`, so an SDK
54
+ * reports `open` before it settles whenever a sheet opened, and `unknown`
55
+ * before it settles when it cannot tell yet (a terminal fact then follows). A transaction
56
+ * update, a pending purchase, or a result is not a `closed` fact.
57
+ */
58
+ export interface StoreSdkPurchasePresentation {
59
+ subscribe(listener: (event: StoreSdkPurchasePresentationEvent) => void): () => void;
60
+ }
61
+ export interface StoreSdkPurchasePresentationEvent {
62
+ readonly presentationId: string;
63
+ /**
64
+ * `unknown`: the call is answering while the native flow still runs and
65
+ * nothing was observed yet; the sheet may still open, and a terminal fact
66
+ * (`closed` or `not-started`) follows. The provider keeps the call waiting
67
+ * for it instead of reading the answer as a sheet that never opened.
68
+ */
69
+ readonly state: 'open' | 'closed' | 'not-started' | 'unknown';
70
+ /** On a `closed` fact no native observation proved, why; see `PlatformPurchasePresentationReason`. */
71
+ readonly reason?: PlatformPurchasePresentationReason;
29
72
  }
30
73
  export interface StoreSdkCapabilities {
31
74
  readonly purchase: CommercePurchaseCapabilities;
@@ -55,6 +98,11 @@ export interface StoreSdkRequestPurchaseInput {
55
98
  readonly offerId?: string;
56
99
  readonly quantity?: number;
57
100
  readonly accountBinding?: StorePurchaseAccountBinding;
101
+ /**
102
+ * Correlates this call's `purchasePresentation` facts. Sent only to an SDK
103
+ * that declares `purchasePresentation`; unique per call.
104
+ */
105
+ readonly presentationId?: string;
58
106
  }
59
107
  export type StoreSdkRequestPurchaseResult = {
60
108
  readonly outcome: 'launched';
@@ -88,13 +136,29 @@ export interface StoreSdkPurchase {
88
136
  /** Store-specific detail the provider never reads; kept for diagnostics only. */
89
137
  readonly raw?: Readonly<Record<string, unknown>>;
90
138
  }
91
- export interface StoreSdkFinishTransactionInput {
139
+ /**
140
+ * What `StoreSdk.finishTransaction` is asked to finish: a purchase the
141
+ * backend ledger granted, or (only for an SDK that declares
142
+ * `acceptsRevokedFinish`) a consumable the store revoked while it was still
143
+ * unfinished, which no grant stands for.
144
+ */
145
+ export type StoreSdkFinishTransactionInput = StoreSdkGrantedFinishTransactionInput | StoreSdkRevokedFinishTransactionInput;
146
+ export interface StoreSdkGrantedFinishTransactionInput {
92
147
  readonly transactionId: string;
93
148
  readonly isConsumable: boolean;
94
149
  /** `FinishTransactionInput.retry`: an earlier finish of this purchase was asked for. */
95
150
  readonly retry?: boolean;
96
151
  /** The ledger entry that authorized the finish; a plugin may record it. */
97
152
  readonly ledgerEntryId: string;
153
+ readonly revoked?: false;
154
+ }
155
+ /** `RevokedFinishTransactionInput`: finish a revoked consumable, granting nothing. */
156
+ export interface StoreSdkRevokedFinishTransactionInput {
157
+ readonly transactionId: string;
158
+ readonly isConsumable: true;
159
+ readonly revoked: true;
160
+ /** The reversed grant's ledger entry; absent when nothing was ever granted. */
161
+ readonly ledgerEntryId?: string;
98
162
  }
99
163
  export interface StoreSdkFinishTransactionResult {
100
164
  readonly finished: boolean;