@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.
- package/README.md +55 -13
- package/dist/commerce-bridge.d.ts +32 -1
- package/dist/commerce-bridge.js +96 -4
- package/dist/index.d.ts +11 -2
- package/dist/index.js +139 -7
- package/dist/providers.d.ts +10 -2
- package/dist/purchase-presentation.d.ts +55 -0
- package/dist/purchase-presentation.js +170 -0
- package/dist/store-commerce-provider.d.ts +16 -2
- package/dist/store-commerce-provider.js +229 -63
- package/dist/store-sdk.d.ts +66 -2
- package/package.json +7 -7
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
-
|
|
255
|
-
|
|
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 (
|
|
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('
|
|
288
|
-
message: `
|
|
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
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
},
|
package/dist/store-sdk.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|