@mpgd/capacitor-play-billing 0.1.7 → 0.2.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 +125 -0
- package/android/src/main/java/dev/mpgd/capacitor/playbilling/CapacitorPlayBillingPlugin.java +68 -10
- package/dist/commerce-provider.d.ts +68 -0
- package/dist/commerce-provider.js +91 -0
- package/dist/definitions.d.ts +60 -7
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/provider.d.ts +15 -2
- package/dist/provider.js +18 -14
- package/dist/store-sdk.d.ts +89 -0
- package/dist/store-sdk.js +613 -0
- package/package.json +11 -7
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
import { Capacitor } from '@capacitor/core';
|
|
2
|
+
import { defaultStoreSdkErrorCodeMap, } from '@mpgd/adapter-capacitor';
|
|
3
|
+
import { createGooglePlayTokenPurchaseKey } from '@mpgd/platform/commerce';
|
|
4
|
+
import { CapacitorPlayBilling } from './plugin.js';
|
|
5
|
+
/** The store id of the Play Billing `StoreSdk` (RFC "Capabilities", `google-play`). */
|
|
6
|
+
export const googlePlayStoreId = 'google-play';
|
|
7
|
+
/**
|
|
8
|
+
* What the Play Billing plugin supports (RFC "Capabilities" profile row for
|
|
9
|
+
* `google-play`): one-time consumables and durables, no subscriptions, no
|
|
10
|
+
* multi-quantity, pending purchases, no restore call, one-time offers, and a
|
|
11
|
+
* server-owned finish (the backend acknowledges or consumes after the grant).
|
|
12
|
+
*/
|
|
13
|
+
export const playBillingStoreSdkCapabilities = Object.freeze({
|
|
14
|
+
purchase: Object.freeze({
|
|
15
|
+
consumable: true,
|
|
16
|
+
durable: true,
|
|
17
|
+
subscription: false,
|
|
18
|
+
quantity: false,
|
|
19
|
+
}),
|
|
20
|
+
offers: true,
|
|
21
|
+
pending: true,
|
|
22
|
+
restore: false,
|
|
23
|
+
finish: 'server',
|
|
24
|
+
});
|
|
25
|
+
/**
|
|
26
|
+
* `defaultStoreSdkErrorCodeMap` plus every `PLAY_BILLING_*` code the plugin or
|
|
27
|
+
* this SDK emits that the default map does not already normalize.
|
|
28
|
+
*/
|
|
29
|
+
export const playBillingStoreSdkErrorCodeMap = Object.freeze({
|
|
30
|
+
...defaultStoreSdkErrorCodeMap,
|
|
31
|
+
PLAY_BILLING_BUSY: 'IN_PROGRESS',
|
|
32
|
+
PLAY_BILLING_OFFER_REQUIRED: 'CONFIGURATION_REQUIRED',
|
|
33
|
+
PLAY_BILLING_INVALID_PRODUCTS: 'CONFIGURATION_REQUIRED',
|
|
34
|
+
PLAY_BILLING_INVALID_PRODUCT: 'CONFIGURATION_REQUIRED',
|
|
35
|
+
PLAY_BILLING_INVALID_REQUEST: 'CONFIGURATION_REQUIRED',
|
|
36
|
+
PLAY_BILLING_ACTIVITY_REQUIRED: 'NOT_PREPARED',
|
|
37
|
+
PLAY_BILLING_ANDROID_REQUIRED: 'STORE_UNAVAILABLE',
|
|
38
|
+
PLAY_BILLING_ERROR: 'UNKNOWN',
|
|
39
|
+
});
|
|
40
|
+
const productIdPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
|
41
|
+
const obfuscatedAccountIdPattern = /^[A-Za-z0-9._-]{1,64}$/u;
|
|
42
|
+
const maximumProducts = 100;
|
|
43
|
+
const maximumPurchaseTimeMillis = 8_640_000_000_000_000;
|
|
44
|
+
const configurationErrorCodes = new Set([
|
|
45
|
+
'PLAY_BILLING_CONFIGURATION_ERROR',
|
|
46
|
+
'PLAY_BILLING_PRODUCT_UNAVAILABLE',
|
|
47
|
+
'PLAY_BILLING_OFFER_REQUIRED',
|
|
48
|
+
'PLAY_BILLING_INVALID_PRODUCTS',
|
|
49
|
+
'PLAY_BILLING_INVALID_PRODUCT',
|
|
50
|
+
]);
|
|
51
|
+
/** A plugin rejection or an SDK integrity failure, carrying the plugin code. */
|
|
52
|
+
export class PlayBillingStoreSdkError extends Error {
|
|
53
|
+
code;
|
|
54
|
+
retryable;
|
|
55
|
+
storeCode;
|
|
56
|
+
constructor(code, message, options = {}) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.name = 'PlayBillingStoreSdkError';
|
|
59
|
+
this.code = code;
|
|
60
|
+
if (options.retryable !== undefined) {
|
|
61
|
+
this.retryable = options.retryable;
|
|
62
|
+
}
|
|
63
|
+
if (options.storeCode !== undefined) {
|
|
64
|
+
this.storeCode = options.storeCode;
|
|
65
|
+
}
|
|
66
|
+
if (options.cause !== undefined) {
|
|
67
|
+
this.cause = options.cause;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* `StoreSdk` over `@mpgd/capacitor-play-billing`'s plugin for
|
|
73
|
+
* `createStoreCommerceProvider`. It speaks Play product ids and plugin codes
|
|
74
|
+
* only: `fetchProducts` is `getProducts`, `requestPurchase` is `purchase` with
|
|
75
|
+
* the obfuscated account id from the request's account binding and the offer
|
|
76
|
+
* token from `offerId`, `getAvailablePurchases` is `getPurchases` filtered to
|
|
77
|
+
* unfinished entries (purchased and not acknowledged, plus pending on
|
|
78
|
+
* request), and `purchaseUpdated` events are the plugin's. A purchased plugin
|
|
79
|
+
* outcome is `purchased`; a pending one is `launched`, because Play resolves
|
|
80
|
+
* it later through `purchaseUpdated` or `getPurchases`; a cancelled one is
|
|
81
|
+
* `cancelled`; a plugin rejection is `failed` with the plugin code, which the
|
|
82
|
+
* provider normalizes (`PLAY_BILLING_TIMEOUT`, `PLAY_BILLING_INTERRUPTED`, and
|
|
83
|
+
* `PLAY_BILLING_EMPTY_PURCHASE` become `OUTCOME_UNKNOWN` and stay recoverable).
|
|
84
|
+
* `transactionId` is the order id or, until Play assigns one, the token key
|
|
85
|
+
* `play-token-sha256:<hex>` v1 ledgers already use. Nothing here grants, and
|
|
86
|
+
* nothing here acknowledges or consumes: the backend finalizer owns both.
|
|
87
|
+
*/
|
|
88
|
+
export function createCapacitorPlayBillingStoreSdk(input) {
|
|
89
|
+
const plugin = input.plugin ?? CapacitorPlayBilling;
|
|
90
|
+
const isAndroid = input.isAndroid ?? (() => Capacitor.getPlatform() === 'android');
|
|
91
|
+
const products = new Map();
|
|
92
|
+
for (const product of input.products) {
|
|
93
|
+
if (!isRecord(product)
|
|
94
|
+
|| typeof product.sku !== 'string' || !productIdPattern.test(product.sku)
|
|
95
|
+
|| products.has(product.sku)
|
|
96
|
+
|| (product.kind !== 'consumable' && product.kind !== 'durable')
|
|
97
|
+
|| (product.offerToken !== undefined
|
|
98
|
+
&& (typeof product.offerToken !== 'string' || product.offerToken.length === 0))) {
|
|
99
|
+
throw new TypeError('Play Billing products must be unique, valid one-time product ids.');
|
|
100
|
+
}
|
|
101
|
+
products.set(product.sku, product);
|
|
102
|
+
}
|
|
103
|
+
if (products.size === 0) {
|
|
104
|
+
throw new TypeError('Play Billing requires at least one one-time product.');
|
|
105
|
+
}
|
|
106
|
+
if (products.size > maximumProducts) {
|
|
107
|
+
throw new TypeError(`Play Billing supports at most ${maximumProducts} products per SDK.`);
|
|
108
|
+
}
|
|
109
|
+
// One plugin listener serves every SDK listener; it attaches with the first
|
|
110
|
+
// and detaches with the last, so an idle SDK holds no native subscription.
|
|
111
|
+
const listeners = new Map();
|
|
112
|
+
let pluginListener;
|
|
113
|
+
let pluginListenerGeneration = 0;
|
|
114
|
+
const emit = (event) => {
|
|
115
|
+
for (const listener of [...(listeners.get(event.type) ?? [])]) {
|
|
116
|
+
listener(event);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
const listenerCount = () => {
|
|
120
|
+
let count = 0;
|
|
121
|
+
for (const set of listeners.values()) {
|
|
122
|
+
count += set.size;
|
|
123
|
+
}
|
|
124
|
+
return count;
|
|
125
|
+
};
|
|
126
|
+
const reportUnmapped = (purchase) => {
|
|
127
|
+
try {
|
|
128
|
+
input.onUnmappedPurchase?.(purchase);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// Diagnostics never break delivery.
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
/** Whether a plugin purchase maps to one configured SKU; anything else is diagnostics only. */
|
|
135
|
+
const isMapped = (purchase) => {
|
|
136
|
+
const sku = skuOf(purchase);
|
|
137
|
+
if (sku !== undefined && products.has(sku)) {
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
reportUnmapped(purchase);
|
|
141
|
+
return false;
|
|
142
|
+
};
|
|
143
|
+
// Deliveries leave in the order the plugin reported them: a purchase with
|
|
144
|
+
// an order id converts and emits synchronously, one without awaits the token
|
|
145
|
+
// digest, and while such a conversion is in flight every later delivery
|
|
146
|
+
// queues behind it instead of overtaking it.
|
|
147
|
+
let deliveries;
|
|
148
|
+
const enqueue = (task) => {
|
|
149
|
+
const settle = (result, previous) => {
|
|
150
|
+
if (result === undefined) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const chained = (previous ?? Promise.resolve())
|
|
154
|
+
.then(() => result)
|
|
155
|
+
.catch(() => undefined)
|
|
156
|
+
.then(() => {
|
|
157
|
+
if (deliveries === chained) {
|
|
158
|
+
deliveries = undefined;
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
deliveries = chained;
|
|
162
|
+
};
|
|
163
|
+
if (deliveries === undefined) {
|
|
164
|
+
settle(task(), undefined);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const pending = deliveries;
|
|
168
|
+
settle(pending.then(task), pending);
|
|
169
|
+
};
|
|
170
|
+
const emitConverted = (purchase) => {
|
|
171
|
+
const deliver = (converted) => {
|
|
172
|
+
emit({ type: 'purchaseUpdated', purchase: converted });
|
|
173
|
+
};
|
|
174
|
+
const fail = (error) => {
|
|
175
|
+
const sku = skuOf(purchase);
|
|
176
|
+
emit({
|
|
177
|
+
type: 'purchaseError',
|
|
178
|
+
...(sku === undefined ? {} : { sku }),
|
|
179
|
+
error: toSdkError(error),
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
let converted;
|
|
183
|
+
try {
|
|
184
|
+
converted = convertPurchase(purchase);
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
fail(error);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (!(converted instanceof Promise)) {
|
|
191
|
+
deliver(converted);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
return converted.then(deliver, fail);
|
|
195
|
+
};
|
|
196
|
+
const onPurchaseUpdated = (outcome) => {
|
|
197
|
+
if (!isRecord(outcome)) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const purchase = outcome.purchase;
|
|
201
|
+
if (purchase === undefined) {
|
|
202
|
+
emitLateTerminalResult(outcome);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (!isRecord(purchase) || !isMapped(purchase)) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
enqueue(() => emitConverted(purchase));
|
|
209
|
+
};
|
|
210
|
+
/**
|
|
211
|
+
* A `cancelled` or `failed` update without a purchase is the plugin's late
|
|
212
|
+
* terminal result for a purchase call that already timed out (the provider
|
|
213
|
+
* answered `launched` for `PLAY_BILLING_TIMEOUT`); it closes that attempt.
|
|
214
|
+
*/
|
|
215
|
+
const emitLateTerminalResult = (outcome) => {
|
|
216
|
+
const sku = typeof outcome.productId === 'string' && products.has(outcome.productId)
|
|
217
|
+
? outcome.productId
|
|
218
|
+
: undefined;
|
|
219
|
+
if (sku === undefined || (outcome.status !== 'cancelled' && outcome.status !== 'failed')) {
|
|
220
|
+
// Without a product the result cannot be correlated to an attempt.
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const error = outcome.status === 'cancelled'
|
|
224
|
+
? { code: 'USER_CANCELLED', retryable: false, message: 'Purchase cancelled.' }
|
|
225
|
+
: {
|
|
226
|
+
code: typeof outcome.code === 'string' && outcome.code.length > 0
|
|
227
|
+
? outcome.code
|
|
228
|
+
: 'PLAY_BILLING_ERROR',
|
|
229
|
+
message: 'Purchase failed after the purchase call timed out.',
|
|
230
|
+
};
|
|
231
|
+
enqueue(() => {
|
|
232
|
+
emit({ type: 'purchaseError', sku, error });
|
|
233
|
+
});
|
|
234
|
+
};
|
|
235
|
+
const attachPluginListener = () => {
|
|
236
|
+
if (pluginListener !== undefined) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const generation = pluginListenerGeneration;
|
|
240
|
+
const handle = (async () => plugin.addListener('purchaseUpdated', (outcome) => {
|
|
241
|
+
if (generation === pluginListenerGeneration) {
|
|
242
|
+
onPurchaseUpdated(outcome);
|
|
243
|
+
}
|
|
244
|
+
}))();
|
|
245
|
+
pluginListener = handle;
|
|
246
|
+
handle.catch(() => {
|
|
247
|
+
// A plugin without the event surface (for example off Android) has
|
|
248
|
+
// nothing to deliver; getAvailablePurchases still serves the reconciler.
|
|
249
|
+
if (pluginListener === handle) {
|
|
250
|
+
pluginListener = undefined;
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
};
|
|
254
|
+
const detachPluginListener = () => {
|
|
255
|
+
const handle = pluginListener;
|
|
256
|
+
if (handle === undefined) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
pluginListener = undefined;
|
|
260
|
+
pluginListenerGeneration += 1;
|
|
261
|
+
handle.then((resolved) => resolved.remove()).catch(() => undefined);
|
|
262
|
+
};
|
|
263
|
+
const sdk = {
|
|
264
|
+
store: googlePlayStoreId,
|
|
265
|
+
capabilities: playBillingStoreSdkCapabilities,
|
|
266
|
+
async fetchProducts(request) {
|
|
267
|
+
assertAndroid(isAndroid);
|
|
268
|
+
if (request.type === 'subscription') {
|
|
269
|
+
return [];
|
|
270
|
+
}
|
|
271
|
+
const skus = request.skus.filter((sku) => products.has(sku));
|
|
272
|
+
if (skus.length === 0) {
|
|
273
|
+
return [];
|
|
274
|
+
}
|
|
275
|
+
const response = await callPlugin(() => plugin.getProducts({ productIds: skus }));
|
|
276
|
+
const reported = [];
|
|
277
|
+
for (const found of readArray(response, 'products')) {
|
|
278
|
+
if (!isRecord(found) || typeof found.productId !== 'string') {
|
|
279
|
+
throw invalidResponse('Play reported a product without an id.');
|
|
280
|
+
}
|
|
281
|
+
const { productId } = found;
|
|
282
|
+
const product = products.get(productId);
|
|
283
|
+
if (product === undefined || !skus.includes(productId)) {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const offers = readOffers(found);
|
|
287
|
+
const selected = selectOffer(offers, product.offerToken);
|
|
288
|
+
if (selected === undefined) {
|
|
289
|
+
// Several eligible offers and no default: a price for the wrong
|
|
290
|
+
// offer is worse than no product, so the catalog must pick one.
|
|
291
|
+
throw new PlayBillingStoreSdkError('PLAY_BILLING_OFFER_REQUIRED', `Play lists ${offers.length} eligible offers for ${productId}; configure an offerToken.`);
|
|
292
|
+
}
|
|
293
|
+
reported.push({
|
|
294
|
+
sku: productId,
|
|
295
|
+
kind: product.kind,
|
|
296
|
+
title: readString(found.title),
|
|
297
|
+
description: readString(found.description),
|
|
298
|
+
price: toPrice(selected),
|
|
299
|
+
offers: offers.map((offer) => ({
|
|
300
|
+
offerId: offer.offerToken,
|
|
301
|
+
price: toPrice(offer),
|
|
302
|
+
})),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
return reported;
|
|
306
|
+
},
|
|
307
|
+
async requestPurchase(request) {
|
|
308
|
+
assertAndroid(isAndroid);
|
|
309
|
+
const product = products.get(request.sku);
|
|
310
|
+
if (product === undefined) {
|
|
311
|
+
throw new PlayBillingStoreSdkError('PLAY_BILLING_PRODUCT_UNKNOWN', `Play Billing does not sell ${request.sku}.`);
|
|
312
|
+
}
|
|
313
|
+
if (request.quantity !== undefined && request.quantity !== 1) {
|
|
314
|
+
throw new PlayBillingStoreSdkError('PLAY_BILLING_INVALID_REQUEST', 'Play Billing purchases one unit per flow.');
|
|
315
|
+
}
|
|
316
|
+
const binding = request.accountBinding;
|
|
317
|
+
if (binding === undefined
|
|
318
|
+
|| binding.kind !== 'obfuscated-account-id'
|
|
319
|
+
|| typeof binding.value !== 'string'
|
|
320
|
+
|| !obfuscatedAccountIdPattern.test(binding.value)) {
|
|
321
|
+
throw new PlayBillingStoreSdkError('PLAY_BILLING_ACCOUNT_REQUIRED', 'Play Billing requires an obfuscated account id that agrees with the backend resolver.');
|
|
322
|
+
}
|
|
323
|
+
const offerToken = request.offerId ?? product.offerToken;
|
|
324
|
+
let outcome;
|
|
325
|
+
try {
|
|
326
|
+
outcome = await plugin.purchase({
|
|
327
|
+
productId: product.sku,
|
|
328
|
+
obfuscatedAccountId: binding.value,
|
|
329
|
+
...(offerToken === undefined ? {} : { offerToken }),
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
return { outcome: 'failed', error: toSdkError(error) };
|
|
334
|
+
}
|
|
335
|
+
if (!isRecord(outcome)) {
|
|
336
|
+
throw invalidResponse('Play returned an invalid purchase outcome.');
|
|
337
|
+
}
|
|
338
|
+
switch (outcome.status) {
|
|
339
|
+
case 'cancelled':
|
|
340
|
+
return {
|
|
341
|
+
outcome: 'cancelled',
|
|
342
|
+
error: { code: 'USER_CANCELLED', retryable: false, message: 'Purchase cancelled.' },
|
|
343
|
+
};
|
|
344
|
+
case 'pending':
|
|
345
|
+
// The purchase exists in the store and completes later;
|
|
346
|
+
// purchaseUpdated or getAvailablePurchases resolves it.
|
|
347
|
+
assertOutcomePurchase(outcome, product.sku, 'pending');
|
|
348
|
+
return { outcome: 'launched' };
|
|
349
|
+
case 'purchased': {
|
|
350
|
+
const purchase = assertOutcomePurchase(outcome, product.sku, 'purchased');
|
|
351
|
+
return { outcome: 'purchased', purchase: await toSdkPurchase(purchase) };
|
|
352
|
+
}
|
|
353
|
+
default:
|
|
354
|
+
throw invalidResponse('Play returned an unknown purchase status.');
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
async getAvailablePurchases(request) {
|
|
358
|
+
assertAndroid(isAndroid);
|
|
359
|
+
const includePending = request.includePending ?? false;
|
|
360
|
+
const response = await callPlugin(() => plugin.getPurchases());
|
|
361
|
+
const available = [];
|
|
362
|
+
for (const purchase of readArray(response, 'purchases')) {
|
|
363
|
+
if (!isRecord(purchase)) {
|
|
364
|
+
throw invalidResponse('Play reported an invalid purchase.');
|
|
365
|
+
}
|
|
366
|
+
if (!isUnfinished(purchase, includePending)) {
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (!isMapped(purchase)) {
|
|
370
|
+
// A retired SKU or a multi-line purchase has no logical id for the
|
|
371
|
+
// reconciler; onUnmappedPurchase sees it.
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
available.push(await toSdkPurchase(purchase));
|
|
375
|
+
}
|
|
376
|
+
return available;
|
|
377
|
+
},
|
|
378
|
+
addListener(eventName, listener) {
|
|
379
|
+
const set = listeners.get(eventName) ?? new Set();
|
|
380
|
+
listeners.set(eventName, set);
|
|
381
|
+
set.add(listener);
|
|
382
|
+
attachPluginListener();
|
|
383
|
+
return () => {
|
|
384
|
+
if (!set.delete(listener)) {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (listenerCount() === 0) {
|
|
388
|
+
detachPluginListener();
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
},
|
|
392
|
+
async getAvailability() {
|
|
393
|
+
if (!isAndroid()) {
|
|
394
|
+
return 'unsupported';
|
|
395
|
+
}
|
|
396
|
+
try {
|
|
397
|
+
const response = await plugin.getProducts({ productIds: [...products.keys()] });
|
|
398
|
+
const purchasable = readArray(response, 'products').some((found) => {
|
|
399
|
+
const product = isRecord(found) && typeof found.productId === 'string'
|
|
400
|
+
? products.get(found.productId)
|
|
401
|
+
: undefined;
|
|
402
|
+
return product !== undefined
|
|
403
|
+
&& selectOffer(readOffers(found), product.offerToken) !== undefined;
|
|
404
|
+
});
|
|
405
|
+
return purchasable ? 'available' : 'configuration-required';
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
const code = isRecord(error) ? error.code : undefined;
|
|
409
|
+
return typeof code === 'string' && configurationErrorCodes.has(code)
|
|
410
|
+
? 'configuration-required'
|
|
411
|
+
: 'temporarily-unavailable';
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
return sdk;
|
|
416
|
+
}
|
|
417
|
+
/** Validates a purchase token exactly as the v1 provider does. */
|
|
418
|
+
export function isValidPlayPurchaseToken(value) {
|
|
419
|
+
return typeof value === 'string'
|
|
420
|
+
&& value.length > 0
|
|
421
|
+
&& value.length <= 4096
|
|
422
|
+
&& value.trim() === value
|
|
423
|
+
&& !/[\u0000-\u001f\u007f]/u.test(value);
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* `StoreSdkPurchase` of one plugin purchase: `token` is the purchase token,
|
|
427
|
+
* `transactionId` is the order id or the token key when Play has not assigned
|
|
428
|
+
* one yet (the v1 rule, so ledgers written by v1 deduplicate), `purchasedAt`
|
|
429
|
+
* is `purchaseTime` as ISO 8601 when it is a valid instant, and the account
|
|
430
|
+
* binding is the obfuscated account id the purchase flow recorded. Throws
|
|
431
|
+
* `PLAY_BILLING_EVIDENCE_INVALID` for a shape the plugin never produces.
|
|
432
|
+
*/
|
|
433
|
+
export async function toPlayBillingSdkPurchase(purchase) {
|
|
434
|
+
return toSdkPurchase(purchase);
|
|
435
|
+
}
|
|
436
|
+
async function toSdkPurchase(purchase) {
|
|
437
|
+
return convertPurchase(purchase);
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Synchronous when Play already assigned an order id; a promise only while
|
|
441
|
+
* the token key is derived, so event delivery is immediate in the common case.
|
|
442
|
+
*/
|
|
443
|
+
function convertPurchase(purchase) {
|
|
444
|
+
if (!isRecord(purchase)) {
|
|
445
|
+
throw evidenceInvalid('Play purchase is not an object.');
|
|
446
|
+
}
|
|
447
|
+
const state = purchase.purchaseState ?? purchase.state;
|
|
448
|
+
if (state !== 'pending' && state !== 'purchased') {
|
|
449
|
+
throw evidenceInvalid('Play purchase state is invalid.');
|
|
450
|
+
}
|
|
451
|
+
const sku = skuOf(purchase);
|
|
452
|
+
if (sku === undefined) {
|
|
453
|
+
throw evidenceInvalid('Play purchase must name exactly one product.');
|
|
454
|
+
}
|
|
455
|
+
const token = purchase.purchaseToken;
|
|
456
|
+
if (!isValidPlayPurchaseToken(token)) {
|
|
457
|
+
throw evidenceInvalid('Play purchase token is invalid.');
|
|
458
|
+
}
|
|
459
|
+
const orderId = typeof purchase.orderId === 'string' ? purchase.orderId.trim() : '';
|
|
460
|
+
const build = (transactionId) => buildSdkPurchase(purchase, { sku, state, token, transactionId });
|
|
461
|
+
if (orderId.length > 0) {
|
|
462
|
+
return build(orderId);
|
|
463
|
+
}
|
|
464
|
+
return createGooglePlayTokenPurchaseKey(token).then(build);
|
|
465
|
+
}
|
|
466
|
+
function buildSdkPurchase(purchase, parsed) {
|
|
467
|
+
const { sku, state, token, transactionId } = parsed;
|
|
468
|
+
const purchaseTime = typeof purchase.purchaseTime === 'number'
|
|
469
|
+
? purchase.purchaseTime
|
|
470
|
+
: purchase.purchaseTimeMillis;
|
|
471
|
+
const purchasedAt = typeof purchaseTime === 'number'
|
|
472
|
+
&& Number.isSafeInteger(purchaseTime)
|
|
473
|
+
&& purchaseTime > 0
|
|
474
|
+
&& purchaseTime <= maximumPurchaseTimeMillis
|
|
475
|
+
? new Date(purchaseTime).toISOString()
|
|
476
|
+
: undefined;
|
|
477
|
+
const quantity = typeof purchase.quantity === 'number'
|
|
478
|
+
&& Number.isSafeInteger(purchase.quantity)
|
|
479
|
+
&& purchase.quantity > 0
|
|
480
|
+
? purchase.quantity
|
|
481
|
+
: 1;
|
|
482
|
+
const accountId = purchase.obfuscatedAccountId;
|
|
483
|
+
return {
|
|
484
|
+
sku,
|
|
485
|
+
transactionId,
|
|
486
|
+
state,
|
|
487
|
+
...(purchasedAt === undefined ? {} : { purchasedAt }),
|
|
488
|
+
token,
|
|
489
|
+
quantity,
|
|
490
|
+
...(typeof accountId === 'string' && accountId.length > 0
|
|
491
|
+
? { accountBinding: { kind: 'obfuscated-account-id', value: accountId } }
|
|
492
|
+
: {}),
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
function skuOf(purchase) {
|
|
496
|
+
if (!isRecord(purchase)) {
|
|
497
|
+
return undefined;
|
|
498
|
+
}
|
|
499
|
+
const products = purchase.products ?? purchase.productIds;
|
|
500
|
+
const sku = Array.isArray(products) && products.length === 1 ? products[0] : undefined;
|
|
501
|
+
return typeof sku === 'string' && productIdPattern.test(sku) ? sku : undefined;
|
|
502
|
+
}
|
|
503
|
+
function isUnfinished(purchase, includePending) {
|
|
504
|
+
const state = purchase.purchaseState ?? purchase.state;
|
|
505
|
+
if (state === 'purchased') {
|
|
506
|
+
// A 0.1 native build reports no flag; an absent flag is unfinished so a
|
|
507
|
+
// paid purchase is never dropped from recovery during a staged upgrade.
|
|
508
|
+
return purchase.isAcknowledged !== true;
|
|
509
|
+
}
|
|
510
|
+
return state === 'pending' && includePending;
|
|
511
|
+
}
|
|
512
|
+
function assertOutcomePurchase(outcome, expectedSku, expectedState) {
|
|
513
|
+
const purchase = outcome.purchase;
|
|
514
|
+
if (!isRecord(purchase)) {
|
|
515
|
+
throw evidenceInvalid('Play purchase outcome carries no purchase.');
|
|
516
|
+
}
|
|
517
|
+
if (skuOf(purchase) !== expectedSku) {
|
|
518
|
+
throw evidenceInvalid('Play purchase did not match the requested product.');
|
|
519
|
+
}
|
|
520
|
+
if ((purchase.purchaseState ?? purchase.state) !== expectedState) {
|
|
521
|
+
throw evidenceInvalid('Play purchase state is inconsistent.');
|
|
522
|
+
}
|
|
523
|
+
return purchase;
|
|
524
|
+
}
|
|
525
|
+
function selectOffer(offers, offerToken) {
|
|
526
|
+
if (offerToken === undefined) {
|
|
527
|
+
return offers.length === 1 ? offers[0] : undefined;
|
|
528
|
+
}
|
|
529
|
+
return offers.find((offer) => offer.offerToken === offerToken);
|
|
530
|
+
}
|
|
531
|
+
function readOffers(found) {
|
|
532
|
+
const offers = isRecord(found) ? found.offers : undefined;
|
|
533
|
+
if (!Array.isArray(offers)) {
|
|
534
|
+
throw invalidResponse('Play reported a product without offers.');
|
|
535
|
+
}
|
|
536
|
+
return offers.map((offer) => {
|
|
537
|
+
if (!isRecord(offer)
|
|
538
|
+
|| typeof offer.offerToken !== 'string' || offer.offerToken.length === 0
|
|
539
|
+
|| typeof offer.formattedPrice !== 'string'
|
|
540
|
+
|| typeof offer.currencyCode !== 'string' || offer.currencyCode.length === 0) {
|
|
541
|
+
throw invalidResponse('Play reported an invalid offer.');
|
|
542
|
+
}
|
|
543
|
+
const micros = offer.priceAmountMicros;
|
|
544
|
+
return {
|
|
545
|
+
offerToken: offer.offerToken,
|
|
546
|
+
formattedPrice: offer.formattedPrice,
|
|
547
|
+
currencyCode: offer.currencyCode,
|
|
548
|
+
...(typeof micros === 'number' && Number.isSafeInteger(micros) && micros >= 0
|
|
549
|
+
? { priceAmountMicros: micros }
|
|
550
|
+
: {}),
|
|
551
|
+
};
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
function toPrice(offer) {
|
|
555
|
+
return {
|
|
556
|
+
formatted: offer.formattedPrice,
|
|
557
|
+
currencyCode: offer.currencyCode,
|
|
558
|
+
...(offer.priceAmountMicros === undefined ? {} : { amountMicros: offer.priceAmountMicros }),
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
function readArray(response, field) {
|
|
562
|
+
const value = isRecord(response) ? response[field] : undefined;
|
|
563
|
+
if (!Array.isArray(value)) {
|
|
564
|
+
throw invalidResponse(`Play returned no ${field} list.`);
|
|
565
|
+
}
|
|
566
|
+
return value;
|
|
567
|
+
}
|
|
568
|
+
function readString(value) {
|
|
569
|
+
return typeof value === 'string' ? value : '';
|
|
570
|
+
}
|
|
571
|
+
async function callPlugin(call) {
|
|
572
|
+
try {
|
|
573
|
+
return await call();
|
|
574
|
+
}
|
|
575
|
+
catch (error) {
|
|
576
|
+
throw toSdkError(error);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
function assertAndroid(isAndroid) {
|
|
580
|
+
if (!isAndroid()) {
|
|
581
|
+
throw new PlayBillingStoreSdkError('PLAY_BILLING_ANDROID_REQUIRED', 'Play Billing is only available on Android.', { retryable: false });
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* A plugin rejection as a `StoreSdkError`: the Capacitor `code` is kept for
|
|
586
|
+
* the provider's error map, with `retryable` and `storeCode` passed through
|
|
587
|
+
* when the rejection carries them; a rejection without a code is
|
|
588
|
+
* `PLAY_BILLING_UNAVAILABLE`.
|
|
589
|
+
*/
|
|
590
|
+
function toSdkError(error) {
|
|
591
|
+
if (error instanceof PlayBillingStoreSdkError) {
|
|
592
|
+
return error;
|
|
593
|
+
}
|
|
594
|
+
if (isRecord(error) && typeof error.code === 'string' && error.code.length > 0) {
|
|
595
|
+
return new PlayBillingStoreSdkError(error.code, typeof error.message === 'string' && error.message.length > 0 ? error.message : error.code, {
|
|
596
|
+
...(typeof error.retryable === 'boolean' ? { retryable: error.retryable } : {}),
|
|
597
|
+
...(typeof error.storeCode === 'string' ? { storeCode: error.storeCode } : {}),
|
|
598
|
+
cause: error,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
return new PlayBillingStoreSdkError('PLAY_BILLING_UNAVAILABLE', error instanceof Error ? error.message : 'Play Billing is unavailable.', { retryable: true, cause: error });
|
|
602
|
+
}
|
|
603
|
+
function evidenceInvalid(message) {
|
|
604
|
+
return new PlayBillingStoreSdkError('PLAY_BILLING_EVIDENCE_INVALID', message, {
|
|
605
|
+
retryable: false,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
function invalidResponse(message) {
|
|
609
|
+
return evidenceInvalid(message);
|
|
610
|
+
}
|
|
611
|
+
function isRecord(value) {
|
|
612
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
613
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/capacitor-play-billing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Opt-in Android Google Play Billing purchase collector for mpgd Capacitor games.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -38,16 +38,20 @@
|
|
|
38
38
|
],
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"@capacitor/core": "^8.5.1",
|
|
41
|
-
"@mpgd/
|
|
42
|
-
"@mpgd/
|
|
41
|
+
"@mpgd/adapter-capacitor": "^0.9.0",
|
|
42
|
+
"@mpgd/bridge": "^0.12.1",
|
|
43
|
+
"@mpgd/platform": "^0.20.0"
|
|
43
44
|
},
|
|
44
45
|
"devDependencies": {
|
|
45
46
|
"@capacitor/core": "8.5.2",
|
|
47
|
+
"@types/node": "^24.0.0",
|
|
46
48
|
"ttsc": "0.30.4",
|
|
47
49
|
"typescript": "7.0.2",
|
|
48
|
-
"
|
|
49
|
-
"@mpgd/
|
|
50
|
-
"@mpgd/
|
|
50
|
+
"vitest": "^4.0.0",
|
|
51
|
+
"@mpgd/adapter-capacitor": "0.9.0",
|
|
52
|
+
"@mpgd/bridge": "0.12.1",
|
|
53
|
+
"@mpgd/game-services": "0.21.0",
|
|
54
|
+
"@mpgd/platform": "0.20.0"
|
|
51
55
|
},
|
|
52
56
|
"main": "./dist/index.js",
|
|
53
57
|
"types": "./dist/index.d.ts",
|
|
@@ -57,6 +61,6 @@
|
|
|
57
61
|
"scripts": {
|
|
58
62
|
"check": "ttsc --noEmit",
|
|
59
63
|
"lint": "ttsc --noEmit",
|
|
60
|
-
"test": "cd ../.. && node tools/run-ttsx.mjs native-plugins/capacitor-play-billing/src/provider.test.ts"
|
|
64
|
+
"test": "vitest run && cd ../.. && node tools/run-ttsx.mjs native-plugins/capacitor-play-billing/src/provider.test.ts"
|
|
61
65
|
}
|
|
62
66
|
}
|