@mpgd/adapter-capacitor 0.8.0 → 0.9.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,437 @@
1
+ import { assertFinishTransactionInput, assertFinishTransactionResult, assertRequestPurchaseSupported, assertStoreProduct, assertStorePurchase, CommerceError, createCommerceEventBuffer, } from '@mpgd/platform/commerce';
2
+ import { defaultStoreSdkErrorCodeMap, normalizeStoreSdkError, } from './store-sdk.js';
3
+ const availabilityTimeoutMs = 3_000;
4
+ const availabilitySet = new Set([
5
+ 'unsupported',
6
+ 'configuration-required',
7
+ 'action-required',
8
+ 'temporarily-unavailable',
9
+ 'available',
10
+ ]);
11
+ /**
12
+ * `CommerceProvider` over an in-process `StoreSdk`. The provider maps logical
13
+ * ids to store SKUs both ways, composes `CommerceCapabilities` from the SDK
14
+ * declaration, builds `StorePurchase` evidence, buffers SDK events until the
15
+ * first subscriber, and normalizes every SDK failure into a `CommerceError`.
16
+ * It never grants and never sets `settlement`; the backend ledger decides.
17
+ */
18
+ export function createStoreCommerceProvider(input) {
19
+ const { sdk } = input;
20
+ const store = input.store ?? sdk.store;
21
+ if (typeof store !== 'string' || store.length === 0) {
22
+ throw new TypeError('A store commerce provider requires a non-empty store id.');
23
+ }
24
+ if (typeof input.evidenceSchema !== 'string' || input.evidenceSchema.length === 0) {
25
+ throw new TypeError('A store commerce provider requires a non-empty evidence schema.');
26
+ }
27
+ const lifecycle = overrideLifecycle(sdk, input.capabilities);
28
+ const finishOwner = lifecycle.finish;
29
+ if ((finishOwner === 'client' || finishOwner === 'host') && sdk.finishTransaction === undefined) {
30
+ throw new TypeError(`Store ${store} declares finish '${finishOwner}' but the SDK has no finishTransaction.`);
31
+ }
32
+ if (lifecycle.restore && sdk.restorePurchases === undefined) {
33
+ // A capability the provider cannot serve is never advertised.
34
+ throw new TypeError(`Store ${store} declares lifecycle.restore but the SDK has no restorePurchases.`);
35
+ }
36
+ const bySku = new Map();
37
+ const byLogicalId = new Map();
38
+ for (const mapping of input.products) {
39
+ if (typeof mapping.logicalId !== 'string' || mapping.logicalId.length === 0
40
+ || typeof mapping.storeSku !== 'string' || mapping.storeSku.length === 0) {
41
+ throw new TypeError('Store product mappings require a logical id and a store SKU.');
42
+ }
43
+ if (bySku.has(mapping.storeSku) || byLogicalId.has(mapping.logicalId)) {
44
+ throw new TypeError(`Store product ${mapping.logicalId} (${mapping.storeSku}) is mapped twice.`);
45
+ }
46
+ bySku.set(mapping.storeSku, mapping);
47
+ byLogicalId.set(mapping.logicalId, mapping);
48
+ }
49
+ const mapError = Object.freeze({
50
+ ...defaultStoreSdkErrorCodeMap,
51
+ ...input.mapError,
52
+ });
53
+ const normalize = (error) => normalizeStoreSdkError(error, mapError);
54
+ const evidencePayload = input.evidencePayload ?? defaultEvidencePayload;
55
+ const toStorePurchase = (purchase) => {
56
+ if (!isRecord(purchase)) {
57
+ throw new CommerceError('INVALID_RESPONSE', {
58
+ message: `Store ${store} reported an invalid purchase.`,
59
+ });
60
+ }
61
+ const mapping = bySku.get(purchase.sku);
62
+ const candidate = {
63
+ store,
64
+ transactionId: purchase.transactionId,
65
+ ...(purchase.originalTransactionId === undefined
66
+ ? {}
67
+ : { originalTransactionId: purchase.originalTransactionId }),
68
+ storeSku: purchase.sku,
69
+ ...(mapping === undefined ? {} : { logicalId: mapping.logicalId }),
70
+ quantity: purchase.quantity,
71
+ state: purchase.state,
72
+ ...(purchase.purchasedAt === undefined ? {} : { purchasedAt: purchase.purchasedAt }),
73
+ token: purchase.token,
74
+ evidence: { schema: input.evidenceSchema, payload: evidencePayload(purchase) },
75
+ ...(purchase.accountBinding === undefined
76
+ ? {}
77
+ : { accountBinding: { ...purchase.accountBinding } }),
78
+ };
79
+ try {
80
+ return assertStorePurchase(candidate);
81
+ }
82
+ catch (error) {
83
+ throw new CommerceError('INVALID_RESPONSE', {
84
+ message: `Store ${store} reported an invalid purchase.`,
85
+ cause: error,
86
+ });
87
+ }
88
+ };
89
+ // Events: the SDK listeners attach at creation so a purchase reported before
90
+ // the first `subscribe` is buffered for it; while no listener is attached the
91
+ // buffer collects again.
92
+ const buffer = createCommerceEventBuffer();
93
+ const listeners = new Set();
94
+ const emit = (event) => {
95
+ if (listeners.size === 0) {
96
+ buffer.push(event);
97
+ return;
98
+ }
99
+ for (const listener of [...listeners]) {
100
+ listener(event);
101
+ }
102
+ };
103
+ const detachers = [
104
+ sdk.addListener('purchaseUpdated', (event) => {
105
+ let purchase;
106
+ try {
107
+ purchase = toStorePurchase(event.purchase);
108
+ }
109
+ catch (error) {
110
+ const mapping = isRecord(event.purchase) && typeof event.purchase.sku === 'string'
111
+ ? bySku.get(event.purchase.sku)
112
+ : undefined;
113
+ emit({
114
+ type: 'purchaseError',
115
+ ...(mapping === undefined ? {} : { logicalId: mapping.logicalId }),
116
+ error: normalize(error).toCommerceErrorInfo(),
117
+ });
118
+ return;
119
+ }
120
+ emit({ type: 'purchaseUpdated', purchase });
121
+ }),
122
+ sdk.addListener('purchaseError', (event) => {
123
+ const mapping = typeof event.sku === 'string' ? bySku.get(event.sku) : undefined;
124
+ const error = normalize(event.error).toCommerceErrorInfo();
125
+ if (recoverableErrorCodes.has(error.code)) {
126
+ // An uncertain or already-owned attempt is not terminal: the reconciler
127
+ // resolves it from `getAvailablePurchases`, so the open record stays open.
128
+ return;
129
+ }
130
+ emit({
131
+ type: 'purchaseError',
132
+ ...(mapping === undefined ? {} : { logicalId: mapping.logicalId }),
133
+ error,
134
+ });
135
+ }),
136
+ ];
137
+ const getAvailability = sdk.getAvailability;
138
+ const readAvailability = async () => {
139
+ if (getAvailability === undefined) {
140
+ return 'available';
141
+ }
142
+ let timer;
143
+ try {
144
+ const reported = await Promise.race([
145
+ Promise.resolve().then(() => getAvailability.call(sdk)),
146
+ new Promise((_resolve, reject) => {
147
+ timer = setTimeout(() => reject(new Error('Store availability timed out.')), availabilityTimeoutMs);
148
+ }),
149
+ ]);
150
+ return availabilitySet.has(reported)
151
+ ? reported
152
+ : 'temporarily-unavailable';
153
+ }
154
+ catch {
155
+ return 'temporarily-unavailable';
156
+ }
157
+ finally {
158
+ if (timer !== undefined) {
159
+ clearTimeout(timer);
160
+ }
161
+ }
162
+ };
163
+ const getCapabilities = async () => ({
164
+ purchase: { ...sdk.capabilities.purchase, ...input.capabilities?.purchase },
165
+ catalog: {
166
+ products: true,
167
+ offers: sdk.capabilities.offers ?? false,
168
+ localizedPrice: true,
169
+ ...input.capabilities?.catalog,
170
+ },
171
+ lifecycle: overrideLifecycle(sdk, input.capabilities),
172
+ authority: { ...input.authority },
173
+ availability: await readAvailability(),
174
+ });
175
+ const finishTransaction = sdk.finishTransaction;
176
+ const restorePurchases = sdk.restorePurchases;
177
+ const provider = {
178
+ store,
179
+ getCapabilities,
180
+ async fetchProducts(request = {}) {
181
+ const type = request.type ?? 'all';
182
+ const mappings = [...byLogicalId.values()].filter((mapping) => (request.logicalIds === undefined || request.logicalIds.includes(mapping.logicalId))
183
+ && (type === 'all'
184
+ || (type === 'subscription') === (mapping.kind === 'subscription')));
185
+ if (mappings.length === 0) {
186
+ return [];
187
+ }
188
+ let reported;
189
+ try {
190
+ reported = await sdk.fetchProducts({
191
+ skus: mappings.map((mapping) => mapping.storeSku),
192
+ type,
193
+ });
194
+ }
195
+ catch (error) {
196
+ throw normalize(error);
197
+ }
198
+ if (!Array.isArray(reported)) {
199
+ throw new CommerceError('INVALID_RESPONSE', {
200
+ message: `Store ${store} returned an invalid product list.`,
201
+ });
202
+ }
203
+ const products = [];
204
+ for (const product of reported) {
205
+ const mapping = isRecord(product) && typeof product.sku === 'string'
206
+ ? bySku.get(product.sku)
207
+ : undefined;
208
+ if (mapping === undefined || !mappings.includes(mapping)) {
209
+ // A SKU the catalog does not sell is not a product the game can buy.
210
+ continue;
211
+ }
212
+ if (product.kind !== mapping.kind) {
213
+ throw new CommerceError('INVALID_RESPONSE', {
214
+ message: `Store ${store} reports ${mapping.storeSku} as ${String(product.kind)}; the catalog declares ${mapping.kind}.`,
215
+ });
216
+ }
217
+ try {
218
+ products.push(assertStoreProduct({
219
+ logicalId: mapping.logicalId,
220
+ store,
221
+ storeSku: mapping.storeSku,
222
+ kind: mapping.kind,
223
+ title: product.title,
224
+ description: product.description,
225
+ price: product.price,
226
+ ...(product.offers === undefined ? {} : { offers: product.offers }),
227
+ }));
228
+ }
229
+ catch (error) {
230
+ throw new CommerceError('INVALID_RESPONSE', {
231
+ message: `Store ${store} reported an invalid product for ${mapping.storeSku}.`,
232
+ cause: error,
233
+ });
234
+ }
235
+ }
236
+ return products;
237
+ },
238
+ 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
+ });
244
+ }
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
+ });
253
+ }
254
+ const offerId = request.offerId ?? mapping.offerId;
255
+ let result;
256
+ 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
+ });
263
+ }
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);
280
+ }
281
+ },
282
+ async getAvailablePurchases(request = {}) {
283
+ if (!overrideLifecycle(sdk, input.capabilities).availablePurchases) {
284
+ return [];
285
+ }
286
+ const includePending = request.includePending ?? false;
287
+ let reported;
288
+ try {
289
+ reported = await sdk.getAvailablePurchases({ includePending });
290
+ }
291
+ catch (error) {
292
+ throw normalize(error);
293
+ }
294
+ if (!Array.isArray(reported)) {
295
+ throw new CommerceError('INVALID_RESPONSE', {
296
+ message: `Store ${store} returned an invalid purchase list.`,
297
+ });
298
+ }
299
+ const purchases = [];
300
+ for (const candidate of reported) {
301
+ const purchase = toStorePurchase(candidate);
302
+ // A revoked purchase is not recoverable, and a pending one is listed
303
+ // only on request; both still reach the reconciler through events.
304
+ if (purchase.state === 'revoked' || (purchase.state === 'pending' && !includePending)) {
305
+ continue;
306
+ }
307
+ purchases.push(purchase);
308
+ }
309
+ return purchases;
310
+ },
311
+ ...(finishTransaction === undefined ? {} : {
312
+ async finishTransaction(request) {
313
+ // Finishing is authorized by a ledger entry only; the id is validated
314
+ // before the store hears about it.
315
+ const finish = assertFinishTransactionInput(request);
316
+ let reported;
317
+ try {
318
+ reported = await finishTransaction.call(sdk, {
319
+ transactionId: finish.transactionId,
320
+ isConsumable: finish.isConsumable,
321
+ ledgerEntryId: finish.ledgerEntryId,
322
+ });
323
+ }
324
+ catch (error) {
325
+ throw normalize(error);
326
+ }
327
+ try {
328
+ return { finished: assertFinishTransactionResult(reported).finished };
329
+ }
330
+ catch (error) {
331
+ throw new CommerceError('INVALID_RESPONSE', {
332
+ message: `Store ${store} returned an invalid finish result.`,
333
+ cause: error,
334
+ });
335
+ }
336
+ },
337
+ }),
338
+ ...(restorePurchases === undefined ? {} : {
339
+ async restorePurchases() {
340
+ try {
341
+ await restorePurchases.call(sdk);
342
+ }
343
+ catch (error) {
344
+ throw normalize(error);
345
+ }
346
+ },
347
+ }),
348
+ subscribe(listener) {
349
+ listeners.add(listener);
350
+ if (listeners.size === 1) {
351
+ buffer.drain(listener);
352
+ }
353
+ return () => {
354
+ listeners.delete(listener);
355
+ };
356
+ },
357
+ dispose() {
358
+ for (const detach of detachers.splice(0, detachers.length)) {
359
+ detach();
360
+ }
361
+ listeners.clear();
362
+ buffer.drain(() => undefined);
363
+ },
364
+ };
365
+ return provider;
366
+ }
367
+ function overrideLifecycle(sdk, overrides) {
368
+ return {
369
+ availablePurchases: true,
370
+ pending: sdk.capabilities.pending,
371
+ restore: sdk.capabilities.restore && sdk.restorePurchases !== undefined,
372
+ events: true,
373
+ finish: sdk.capabilities.finish,
374
+ ...overrides?.lifecycle,
375
+ };
376
+ }
377
+ function defaultEvidencePayload(purchase) {
378
+ return {
379
+ token: purchase.token,
380
+ transactionId: purchase.transactionId,
381
+ storeSku: purchase.sku,
382
+ ...(purchase.originalTransactionId === undefined
383
+ ? {}
384
+ : { originalTransactionId: purchase.originalTransactionId }),
385
+ };
386
+ }
387
+ function assertAvailable(availability, store) {
388
+ switch (availability) {
389
+ case 'available':
390
+ return;
391
+ case 'configuration-required':
392
+ throw new CommerceError('CONFIGURATION_REQUIRED', {
393
+ message: `Store ${store} is not configured.`,
394
+ });
395
+ case 'action-required':
396
+ throw new CommerceError('ACTION_REQUIRED', {
397
+ message: `Store ${store} needs the player to act before a purchase.`,
398
+ });
399
+ case 'temporarily-unavailable':
400
+ throw new CommerceError('STORE_UNAVAILABLE', {
401
+ message: `Store ${store} is temporarily unavailable.`,
402
+ retryable: true,
403
+ });
404
+ case 'unsupported':
405
+ throw new CommerceError('STORE_UNAVAILABLE', {
406
+ message: `Store ${store} is unsupported on this device.`,
407
+ retryable: false,
408
+ });
409
+ }
410
+ }
411
+ /**
412
+ * Codes that mean an unfinished purchase may exist (RFC "Errors": reconcile,
413
+ * never reopen the store UI blindly). The attempt stays `launched` so the
414
+ * reconciler resolves it from `getAvailablePurchases`; a terminal `failed`
415
+ * would exclude it from recovery.
416
+ */
417
+ export const recoverableErrorCodes = new Set([
418
+ 'OUTCOME_UNKNOWN',
419
+ 'ALREADY_OWNED',
420
+ ]);
421
+ function failureOutcome(outcome, error) {
422
+ if (error.code === 'USER_CANCELLED') {
423
+ return { outcome: 'cancelled', error };
424
+ }
425
+ if (recoverableErrorCodes.has(error.code)) {
426
+ return { outcome: 'launched' };
427
+ }
428
+ return { outcome: outcome ?? 'failed', error };
429
+ }
430
+ function invalidRequestResult(store) {
431
+ return new CommerceError('INVALID_RESPONSE', {
432
+ message: `Store ${store} returned an invalid purchase result.`,
433
+ });
434
+ }
435
+ function isRecord(value) {
436
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
437
+ }
@@ -0,0 +1,131 @@
1
+ import type { PlatformProviderAvailability } from '@mpgd/platform';
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
+ /**
4
+ * In-process surface a store plugin exposes to `createStoreCommerceProvider`.
5
+ * The shape follows the OpenIAP Client Protocol (`fetchProducts`,
6
+ * `requestPurchase` with results delivered by events, `getAvailablePurchases`,
7
+ * `finishTransaction`) so a future Capacitor IAP plugin over the OpenIAP cores
8
+ * is a drop-in, while the kit's Play Billing and StoreKit plugins adapt to it
9
+ * in B2 and B3. The SDK speaks store SKUs only; the provider owns the mapping
10
+ * to logical product ids, evidence, and capability composition. Nothing here
11
+ * grants: a purchase reported by the SDK is evidence for the backend ledger.
12
+ */
13
+ export interface StoreSdk {
14
+ readonly store: StoreId;
15
+ /** Static declaration of what the SDK supports; the provider composes `CommerceCapabilities` from it. */
16
+ readonly capabilities: StoreSdkCapabilities;
17
+ fetchProducts(input: StoreSdkFetchProductsInput): Promise<readonly StoreSdkProduct[]>;
18
+ /** Returns `launched` when the store UI opened and the result arrives as a `purchaseUpdated` event. */
19
+ requestPurchase(input: StoreSdkRequestPurchaseInput): Promise<StoreSdkRequestPurchaseResult>;
20
+ /** Purchases the store still reports as unfinished. */
21
+ getAvailablePurchases(input: StoreSdkGetAvailablePurchasesInput): Promise<readonly StoreSdkPurchase[]>;
22
+ /** Present when `capabilities.finish` is 'client' or 'host'. Called only after a ledger grant. */
23
+ finishTransaction?(input: StoreSdkFinishTransactionInput): Promise<StoreSdkFinishTransactionResult>;
24
+ /** Asks the store to resynchronize, for example `AppStore.sync`. */
25
+ restorePurchases?(): Promise<void>;
26
+ addListener<TEvent extends StoreSdkEventName>(eventName: TEvent, listener: (event: StoreSdkEventOf<TEvent>) => void): () => void;
27
+ /** Current readiness; omitted means the SDK is in-process and ready. */
28
+ getAvailability?(): Promise<PlatformProviderAvailability>;
29
+ }
30
+ export interface StoreSdkCapabilities {
31
+ readonly purchase: CommercePurchaseCapabilities;
32
+ /** The SDK reports store offers on products. Defaults to false. */
33
+ readonly offers?: boolean;
34
+ /** The store can report a purchase in state `pending` (for example deferred payment). */
35
+ readonly pending: boolean;
36
+ /** `restorePurchases` is implemented. */
37
+ readonly restore: boolean;
38
+ /** Who finishes a granted purchase; 'client' and 'host' require `finishTransaction`. */
39
+ readonly finish: CommerceFinishOwner;
40
+ }
41
+ export interface StoreSdkFetchProductsInput {
42
+ readonly skus: readonly string[];
43
+ readonly type: FetchProductsType;
44
+ }
45
+ export interface StoreSdkProduct {
46
+ readonly sku: string;
47
+ readonly kind: PurchaseKind;
48
+ readonly title: string;
49
+ readonly description: string;
50
+ readonly price: StorePrice;
51
+ readonly offers?: readonly StoreOffer[];
52
+ }
53
+ export interface StoreSdkRequestPurchaseInput {
54
+ readonly sku: string;
55
+ readonly offerId?: string;
56
+ readonly quantity?: number;
57
+ readonly accountBinding?: StorePurchaseAccountBinding;
58
+ }
59
+ export type StoreSdkRequestPurchaseResult = {
60
+ readonly outcome: 'launched';
61
+ } | {
62
+ readonly outcome: 'purchased';
63
+ readonly purchase: StoreSdkPurchase;
64
+ } | {
65
+ readonly outcome: 'cancelled' | 'failed';
66
+ readonly error: StoreSdkError;
67
+ };
68
+ export interface StoreSdkGetAvailablePurchasesInput {
69
+ readonly includePending?: boolean;
70
+ }
71
+ export interface StoreSdkPurchase {
72
+ readonly sku: string;
73
+ readonly transactionId: string;
74
+ readonly originalTransactionId?: string;
75
+ readonly state: StorePurchaseState;
76
+ readonly purchasedAt?: string;
77
+ /** Play purchase token, App Store signed transaction (JWS), or host order id. */
78
+ readonly token: string;
79
+ readonly quantity: number;
80
+ readonly accountBinding?: StorePurchaseAccountBinding;
81
+ /** Store-specific detail the provider never reads; kept for diagnostics only. */
82
+ readonly raw?: Readonly<Record<string, unknown>>;
83
+ }
84
+ export interface StoreSdkFinishTransactionInput {
85
+ readonly transactionId: string;
86
+ readonly isConsumable: boolean;
87
+ /** The ledger entry that authorized the finish; a plugin may record it. */
88
+ readonly ledgerEntryId: string;
89
+ }
90
+ export interface StoreSdkFinishTransactionResult {
91
+ readonly finished: boolean;
92
+ }
93
+ /**
94
+ * Failure thrown or returned by an SDK. `code` is the store's own code (for
95
+ * example `PLAY_BILLING_SERVICE_UNAVAILABLE`) or an already normalized
96
+ * `CommerceErrorCode`; `storeCode` keeps the store code when `code` is already
97
+ * normalized. `retryable` overrides the default of the normalized code.
98
+ */
99
+ export interface StoreSdkError {
100
+ readonly code: string;
101
+ readonly message?: string;
102
+ readonly retryable?: boolean;
103
+ readonly storeCode?: string;
104
+ }
105
+ export type StoreSdkEvent = {
106
+ readonly type: 'purchaseUpdated';
107
+ readonly purchase: StoreSdkPurchase;
108
+ } | {
109
+ readonly type: 'purchaseError';
110
+ readonly sku?: string;
111
+ readonly error: StoreSdkError;
112
+ };
113
+ export type StoreSdkEventName = StoreSdkEvent['type'];
114
+ export type StoreSdkEventOf<TName extends StoreSdkEventName> = Extract<StoreSdkEvent, {
115
+ readonly type: TName;
116
+ }>;
117
+ /** Store code to normalized code; a provider passes a per-store table merged over these defaults. */
118
+ export type StoreSdkErrorCodeMap = Readonly<Record<string, CommerceErrorCode>>;
119
+ /**
120
+ * Default mapping from the kit's current Play Billing, StoreKit, and native
121
+ * provider codes (RFC "Errors"). B2 and B3 extend it per store.
122
+ */
123
+ export declare const defaultStoreSdkErrorCodeMap: StoreSdkErrorCodeMap;
124
+ /**
125
+ * Normalizes anything an SDK threw or returned into a `CommerceError`. A store
126
+ * code found in `mapError` becomes the mapped code with the store code kept;
127
+ * a code that is already a v2 code is kept as is; anything else is `UNKNOWN`
128
+ * with the original code as `storeCode`. An explicit `retryable` wins over
129
+ * the default of the normalized code.
130
+ */
131
+ export declare function normalizeStoreSdkError(error: unknown, mapError?: StoreSdkErrorCodeMap): CommerceError;
@@ -0,0 +1,83 @@
1
+ import { CommerceError, commerceErrorInfoOf, isCommerceErrorCode, } from '@mpgd/platform/commerce';
2
+ /**
3
+ * Default mapping from the kit's current Play Billing, StoreKit, and native
4
+ * provider codes (RFC "Errors"). B2 and B3 extend it per store.
5
+ */
6
+ export const defaultStoreSdkErrorCodeMap = Object.freeze({
7
+ PLAY_BILLING_TIMEOUT: 'OUTCOME_UNKNOWN',
8
+ PLAY_BILLING_INTERRUPTED: 'OUTCOME_UNKNOWN',
9
+ PLAY_BILLING_EMPTY_PURCHASE: 'OUTCOME_UNKNOWN',
10
+ STOREKIT_PURCHASE_UNCERTAIN: 'OUTCOME_UNKNOWN',
11
+ PLAY_BILLING_ALREADY_OWNED: 'ALREADY_OWNED',
12
+ PLAY_BILLING_DISCONNECTED: 'STORE_UNAVAILABLE',
13
+ PLAY_BILLING_SERVICE_UNAVAILABLE: 'STORE_UNAVAILABLE',
14
+ PLAY_BILLING_UNAVAILABLE: 'STORE_UNAVAILABLE',
15
+ PLAY_BILLING_TRANSIENT_ERROR: 'STORE_UNAVAILABLE',
16
+ STOREKIT_UNAVAILABLE: 'STORE_UNAVAILABLE',
17
+ NATIVE_PROVIDER_TEMPORARILY_UNAVAILABLE: 'STORE_UNAVAILABLE',
18
+ NATIVE_PROVIDER_UNSUPPORTED: 'STORE_UNAVAILABLE',
19
+ PLAY_BILLING_NETWORK_ERROR: 'NETWORK',
20
+ NATIVE_PURCHASE_NETWORK_ERROR: 'NETWORK',
21
+ PLAY_BILLING_PRODUCT_UNKNOWN: 'ITEM_UNAVAILABLE',
22
+ PLAY_BILLING_PRODUCT_UNAVAILABLE: 'ITEM_UNAVAILABLE',
23
+ STOREKIT_PRODUCT_UNKNOWN: 'ITEM_UNAVAILABLE',
24
+ STOREKIT_PRODUCT_UNAVAILABLE: 'ITEM_UNAVAILABLE',
25
+ NATIVE_PROVIDER_PRODUCT_UNKNOWN: 'ITEM_UNAVAILABLE',
26
+ PLAY_BILLING_ACCOUNT_REQUIRED: 'ACCOUNT_BINDING_REQUIRED',
27
+ PLAY_BILLING_ACCOUNT_LOOKUP_FAILED: 'ACCOUNT_BINDING_REQUIRED',
28
+ PLAY_BILLING_CONFIGURATION_ERROR: 'CONFIGURATION_REQUIRED',
29
+ STOREKIT_CONFIGURATION_ERROR: 'CONFIGURATION_REQUIRED',
30
+ NATIVE_PROVIDER_CONFIGURATION_REQUIRED: 'CONFIGURATION_REQUIRED',
31
+ NATIVE_PROVIDER_ACTION_REQUIRED: 'ACTION_REQUIRED',
32
+ NATIVE_PROVIDER_NOT_READY: 'NOT_PREPARED',
33
+ PLAY_BILLING_EVIDENCE_INVALID: 'INVALID_RESPONSE',
34
+ STOREKIT_EVIDENCE_INVALID: 'INVALID_RESPONSE',
35
+ NATIVE_PROVIDER_INVALID_RESPONSE: 'INVALID_RESPONSE',
36
+ });
37
+ /**
38
+ * Normalizes anything an SDK threw or returned into a `CommerceError`. A store
39
+ * code found in `mapError` becomes the mapped code with the store code kept;
40
+ * a code that is already a v2 code is kept as is; anything else is `UNKNOWN`
41
+ * with the original code as `storeCode`. An explicit `retryable` wins over
42
+ * the default of the normalized code.
43
+ */
44
+ export function normalizeStoreSdkError(error, mapError = defaultStoreSdkErrorCodeMap) {
45
+ if (error instanceof CommerceError) {
46
+ return error;
47
+ }
48
+ if (typeof error === 'object' && error !== null && !Array.isArray(error)) {
49
+ const record = error;
50
+ if (typeof record.code === 'string' && record.code.length > 0) {
51
+ const message = typeof record.message === 'string' ? record.message : undefined;
52
+ const retryable = typeof record.retryable === 'boolean' ? record.retryable : undefined;
53
+ const storeCode = typeof record.storeCode === 'string' ? record.storeCode : undefined;
54
+ const options = {
55
+ ...(message === undefined ? {} : { message }),
56
+ ...(retryable === undefined ? {} : { retryable }),
57
+ cause: error,
58
+ };
59
+ const mapped = Object.hasOwn(mapError, record.code) ? mapError[record.code] : undefined;
60
+ if (mapped !== undefined) {
61
+ return new CommerceError(mapped, { ...options, storeCode: storeCode ?? record.code });
62
+ }
63
+ if (isCommerceErrorCode(record.code)) {
64
+ return new CommerceError(record.code, {
65
+ ...options,
66
+ ...(storeCode === undefined ? {} : { storeCode }),
67
+ });
68
+ }
69
+ return new CommerceError('UNKNOWN', {
70
+ ...options,
71
+ retryable: retryable ?? false,
72
+ storeCode: storeCode ?? record.code,
73
+ });
74
+ }
75
+ }
76
+ const info = commerceErrorInfoOf(error);
77
+ return new CommerceError(info.code, {
78
+ retryable: info.retryable,
79
+ ...(info.storeCode === undefined ? {} : { storeCode: info.storeCode }),
80
+ ...(info.message === undefined ? {} : { message: info.message }),
81
+ cause: error,
82
+ });
83
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/adapter-capacitor",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Capacitor platform adapter for mpgd Android and iOS shells.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -43,11 +43,11 @@
43
43
  ],
44
44
  "dependencies": {
45
45
  "@capacitor/app": "8.1.1",
46
- "@mpgd/capacitor-game-services": "0.6.2",
47
- "@mpgd/bridge": "0.12.0",
48
- "@mpgd/platform": "0.19.0",
49
- "@mpgd/game-services": "0.20.0",
50
- "@mpgd/target-config": "0.21.2"
46
+ "@mpgd/bridge": "0.12.1",
47
+ "@mpgd/capacitor-game-services": "0.6.3",
48
+ "@mpgd/game-services": "0.21.0",
49
+ "@mpgd/platform": "0.20.0",
50
+ "@mpgd/target-config": "0.21.3"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "@capacitor-community/admob": "^8.1.0",
@@ -64,7 +64,7 @@
64
64
  "ttsc": "0.30.4",
65
65
  "typescript": "7.0.2",
66
66
  "vitest": "^4.0.0",
67
- "@mpgd/game-runtime": "0.6.2"
67
+ "@mpgd/game-runtime": "0.6.3"
68
68
  },
69
69
  "main": "./dist/index.js",
70
70
  "types": "./dist/index.d.ts",