@mpgd/capacitor-play-billing 0.1.8 → 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 CHANGED
@@ -13,6 +13,131 @@ This package is published for opt-in integration, but Kit's mock and compile
13
13
  checks do not establish live Play purchase behavior. Validate it in a
14
14
  game-owned Play Console project before relying on it in a published game.
15
15
 
16
+ ## Commerce v2 provider
17
+
18
+ Commerce v2 ([RFC](../../docs/specs/commerce/commerce-v2-rfc.md), PR B2) adds
19
+ a `StoreSdk` over this plugin and a `CommerceProvider` built from it with
20
+ `createStoreCommerceProvider` from `@mpgd/adapter-capacitor` (a peer
21
+ dependency since 0.2.0). Register the service provider with the Capacitor
22
+ gateway and it serves the six `commerce.v2.*` bridge methods as
23
+ `gateway.commerceV2`, plus the four v1 `commerce.*` methods through
24
+ `toCommerceAdapter`, so v1 callers keep working.
25
+
26
+ ```ts
27
+ import { createCapacitorPlatformGateway } from '@mpgd/adapter-capacitor';
28
+ import { createCapacitorPlayBillingServiceProvider } from '@mpgd/capacitor-play-billing';
29
+
30
+ export function createPlayGateway(getObfuscatedAccountId: () => Promise<string>) {
31
+ return createCapacitorPlatformGateway({
32
+ target: 'android',
33
+ appVersion: '1.0.0',
34
+ buildId: 'game-build',
35
+ providers: [
36
+ createCapacitorPlayBillingServiceProvider({
37
+ products: [
38
+ { logicalId: 'gems_small', storeSku: 'gems.small', kind: 'consumable' },
39
+ { logicalId: 'remove_ads', storeSku: 'remove.ads', kind: 'durable', offerId: 'intro' },
40
+ ],
41
+ // Must agree with the backend's resolveObfuscatedAccountId.
42
+ accountBinding: async () => ({
43
+ kind: 'obfuscated-account-id',
44
+ value: await getObfuscatedAccountId(),
45
+ }),
46
+ }),
47
+ ],
48
+ });
49
+ }
50
+ ```
51
+
52
+ `createCapacitorPlayBillingStoreSdk({ products, plugin?, isAndroid? })` is
53
+ the `StoreSdk`: store `google-play`; capabilities consumable and durable
54
+ (no subscriptions, no multi-quantity), `pending: true`, `restore: false`,
55
+ `offers: true`, and `finish: 'server'`. `fetchProducts` is `getProducts`
56
+ with the selected one-time offer as the price (`PLAY_BILLING_OFFER_REQUIRED`
57
+ when several offers are eligible and none is configured) and every eligible
58
+ offer listed as `offers[].offerId` (the Play `offerToken`). `requestPurchase`
59
+ is `purchase` with the obfuscated account id from the request's
60
+ `accountBinding` (`kind: 'obfuscated-account-id'`; anything else is
61
+ `PLAY_BILLING_ACCOUNT_REQUIRED`) and `offerToken` from `offerId`: a
62
+ `purchased` outcome is `purchased`, a `pending` outcome is `launched` because
63
+ Play resolves it later through `purchaseUpdated` or `getPurchases`, a
64
+ `cancelled` outcome is `cancelled`, and a plugin rejection is `failed` with
65
+ the plugin code. `getAvailablePurchases` is `getPurchases` filtered to
66
+ unfinished entries: `purchased` with `isAcknowledged: false`, plus `pending`
67
+ when `includePending` is set; each carries `token = purchaseToken` and
68
+ `transactionId = orderId`, or the `play-token-sha256:<hex>` token key until
69
+ Play assigns an order id, so ledgers written by v1 deduplicate. A purchased
70
+ entry from a 0.1 native build carries no `isAcknowledged`; an absent flag
71
+ counts as unfinished so a paid purchase is never dropped during a staged
72
+ upgrade. A purchase for a SKU that is not in `products` (a retired product)
73
+ or a multi-line purchase has no logical id for the reconciler, so it is left
74
+ out of the list and of events and handed to the optional `onUnmappedPurchase`
75
+ diagnostics hook instead; the backend finalizer still sees it through the
76
+ Play Developer API. The plugin's `purchaseUpdated` listener backs
77
+ `addListener`: deliveries leave in the order the plugin reported them (a
78
+ purchase without an order id waits for the token digest, and later updates
79
+ queue behind it), and a `cancelled` or `failed` update without a purchase is
80
+ the plugin's late terminal result for a purchase call that already timed out
81
+ (`PLAY_BILLING_TIMEOUT`), emitted as a `purchaseError` for that product so
82
+ the attempt the timeout left open closes. `getAvailability` is the v1 logic
83
+ (Android check, offer configuration, service state).
84
+ `playBillingStoreSdkErrorCodeMap` extends `defaultStoreSdkErrorCodeMap` with
85
+ every other `PLAY_BILLING_*` code (`BUSY` is `IN_PROGRESS`, `OFFER_REQUIRED`
86
+ and the invalid-product codes are `CONFIGURATION_REQUIRED`,
87
+ `ACTIVITY_REQUIRED` is `NOT_PREPARED`, `ANDROID_REQUIRED` is
88
+ `STORE_UNAVAILABLE`); `PLAY_BILLING_TIMEOUT`, `PLAY_BILLING_INTERRUPTED`,
89
+ `PLAY_BILLING_EMPTY_PURCHASE`, and `PLAY_BILLING_ALREADY_OWNED` stay
90
+ recoverable, so the provider answers `launched` and the reconciler resolves
91
+ the attempt from `getAvailablePurchases` without reopening the store UI.
92
+
93
+ `createCapacitorPlayBillingCommerceProvider({ products, accountBinding,
94
+ plugin?, isAndroid?, store?, mapError? })` composes the provider: evidence
95
+ schema `google-play.product-purchase.v2` with payload `{ purchaseToken }`,
96
+ byte-identical to the v1 provider's evidence, so
97
+ `createGooglePlayProductPurchaseBoundary` verifies both; authority
98
+ `{ evidence: 'client-token', accountBinding: 'required' }`; no
99
+ `finishTransaction` and no `restorePurchases`. A thrown `accountBinding`
100
+ lookup is `ACCOUNT_BINDING_REQUIRED` with `PLAY_BILLING_ACCOUNT_LOOKUP_FAILED`
101
+ kept as the store code. `store` defaults to `google-play`; only a conformance
102
+ harness overrides it, because the derived idempotency key follows the store
103
+ id. The conformance test runs every store vector whose profile the Play
104
+ capabilities can serve (`purchase-server-finish`) against a fake plugin,
105
+ directly and through the gateway bridge path, and lists the skipped vectors
106
+ with their reason: the client-finish, host-finish, adapter-settled, and
107
+ server-push profiles need a finish owner or evidence authority Play does not
108
+ have.
109
+
110
+ The plugin surface gained the Billing Library 9.1.0 `Purchase` getters on
111
+ every purchase (`getPurchases()`, `purchase()`, and `purchaseUpdated`):
112
+ `products`, `purchaseState`, `purchaseTime`, `quantity`, `isAcknowledged`,
113
+ and `obfuscatedAccountId`, with `priceAmountMicros` on offers. The 0.1 names
114
+ `productIds`, `state`, and `purchaseTimeMillis` carry the same values and are
115
+ deprecated until the next major version. `purchaseUpdated` is now retained
116
+ until a listener consumes it, so a purchase that completes after the
117
+ `purchase` call timed out, or before the web view attached its listener,
118
+ still reaches the provider, and a cancel or non-OK result that arrives after
119
+ the 180-second `purchase` timeout is delivered as `{ status: 'cancelled' |
120
+ 'failed', productId, code? }` for the timed-out product instead of being
121
+ discarded. The plugin still never acknowledges or consumes;
122
+ `isAcknowledged: false` on a purchased entry marks it unfinished for the
123
+ backend finalizer.
124
+
125
+ ### Migrating from the v1 provider
126
+
127
+ `createCapacitorPlayBillingProvider` and `recoverOwnedPlayPurchases` are
128
+ unchanged and keep working; both providers can share the plugin. Games that
129
+ move to v2 replace the v1 provider in `providers` with
130
+ `createCapacitorPlayBillingServiceProvider`, turn `getObfuscatedAccountId`
131
+ into an `accountBinding` resolver returning `{ kind: 'obfuscated-account-id',
132
+ value }`, rename `id`/`storeId`/`type` to `logicalId`/`storeSku`/`kind`
133
+ (`non_consumable` becomes `durable`) and `offerToken` to `offerId`, and stop
134
+ calling `recoverOwnedPlayPurchases`: it is deprecated in favour of the single
135
+ reconciler landing in `@mpgd/game-services` (PR C1), which reads
136
+ `getAvailablePurchases` and `purchaseUpdated` from `gateway.commerceV2`. The
137
+ backend contract is unchanged, since the evidence payload is the same.
138
+
139
+ ## v1 provider
140
+
16
141
  The provider maps game logical product IDs to Play product IDs and requires a
17
142
  stable obfuscated account ID that agrees with the backend account resolver.
18
143
  When Play returns multiple eligible one-time offers, map an explicit
@@ -2,6 +2,7 @@ package dev.mpgd.capacitor.playbilling;
2
2
 
3
3
  import android.os.Handler;
4
4
  import android.os.Looper;
5
+ import com.android.billingclient.api.AccountIdentifiers;
5
6
  import com.android.billingclient.api.BillingClient;
6
7
  import com.android.billingclient.api.BillingClientStateListener;
7
8
  import com.android.billingclient.api.BillingFlowParams;
@@ -22,7 +23,11 @@ import java.util.Collections;
22
23
  import java.util.List;
23
24
  import org.json.JSONException;
24
25
 
25
- /** Purchase collection only. Finalization is server-owned after the mpgd ledger grant. */
26
+ /**
27
+ * Purchase collection only. Finalization is server-owned after the mpgd ledger grant: this
28
+ * plugin never calls acknowledgePurchase or consumeAsync, and the encoded isAcknowledged flag
29
+ * lets the TypeScript provider list unfinished purchases for the backend finalizer.
30
+ */
26
31
  @CapacitorPlugin(name = "CapacitorPlayBilling")
27
32
  public class CapacitorPlayBillingPlugin extends Plugin {
28
33
  private BillingClient billingClient;
@@ -32,6 +37,12 @@ public class CapacitorPlayBillingPlugin extends Plugin {
32
37
  private PluginCall activePurchase;
33
38
  private String activeProductId;
34
39
  private Runnable purchaseTimeout;
40
+ /**
41
+ * Product of a purchase call that timed out while the Play UI was still up. A later
42
+ * terminal callback (cancel or non-OK result) is delivered for it as a purchaseUpdated
43
+ * event, so the JavaScript attempt the timeout left open can close.
44
+ */
45
+ private String timedOutProductId;
35
46
 
36
47
  @Override
37
48
  public void load() {
@@ -107,7 +118,8 @@ public class CapacitorPlayBillingPlugin extends Plugin {
107
118
  eligibleOffers.put(new JSObject()
108
119
  .put("offerToken", offer.getOfferToken())
109
120
  .put("formattedPrice", offer.getFormattedPrice())
110
- .put("currencyCode", offer.getPriceCurrencyCode()));
121
+ .put("currencyCode", offer.getPriceCurrencyCode())
122
+ .put("priceAmountMicros", offer.getPriceAmountMicros()));
111
123
  }
112
124
  }
113
125
  if (eligibleOffers.length() == 0) {
@@ -144,8 +156,10 @@ public class CapacitorPlayBillingPlugin extends Plugin {
144
156
  }
145
157
  activePurchase = call;
146
158
  activeProductId = productId;
159
+ timedOutProductId = null;
147
160
  purchaseTimeout = () -> {
148
161
  if (activePurchase == call) {
162
+ timedOutProductId = activeProductId;
149
163
  clearActivePurchase(call);
150
164
  call.reject("Purchase confirmation timed out; requery owned purchases.",
151
165
  "PLAY_BILLING_TIMEOUT");
@@ -248,6 +262,8 @@ public class CapacitorPlayBillingPlugin extends Plugin {
248
262
  if (pending != null) {
249
263
  clearActivePurchase(pending);
250
264
  pending.resolve(new JSObject().put("status", "cancelled"));
265
+ } else {
266
+ notifyLateTerminalResult("cancelled", null);
251
267
  }
252
268
  return;
253
269
  }
@@ -255,6 +271,8 @@ public class CapacitorPlayBillingPlugin extends Plugin {
255
271
  if (pending != null) {
256
272
  clearActivePurchase(pending);
257
273
  rejectBilling(pending, result);
274
+ } else {
275
+ notifyLateTerminalResult("failed", billingErrorCode(result));
258
276
  }
259
277
  return;
260
278
  }
@@ -272,9 +290,15 @@ public class CapacitorPlayBillingPlugin extends Plugin {
272
290
  continue;
273
291
  }
274
292
  JSObject outcome = new JSObject()
275
- .put("status", encoded.optString("state"))
293
+ .put("status", encoded.optString("purchaseState"))
276
294
  .put("purchase", encoded);
277
- notifyListeners("purchaseUpdated", outcome);
295
+ // Retained until a listener consumes it: a purchase that completes after the
296
+ // purchase call timed out, or before the web view attached its listener, must
297
+ // still reach the provider's purchaseUpdated subscription.
298
+ notifyListeners("purchaseUpdated", outcome, true);
299
+ if (timedOutProductId != null && purchase.getProducts().contains(timedOutProductId)) {
300
+ timedOutProductId = null;
301
+ }
278
302
  if (pending != null && purchase.getProducts().contains(expectedProductId)) {
279
303
  clearActivePurchase(pending);
280
304
  pending.resolve(outcome);
@@ -283,6 +307,24 @@ public class CapacitorPlayBillingPlugin extends Plugin {
283
307
  }
284
308
  }
285
309
 
310
+ /**
311
+ * A cancel or non-OK result that arrives after the purchase call already timed out has no
312
+ * call to answer; without this event the JavaScript attempt would stay open forever and
313
+ * block another purchase of the product. Only the timed-out product is correlated.
314
+ */
315
+ private void notifyLateTerminalResult(String status, String code) {
316
+ String productId = timedOutProductId;
317
+ if (productId == null) {
318
+ return;
319
+ }
320
+ timedOutProductId = null;
321
+ JSObject outcome = new JSObject().put("status", status).put("productId", productId);
322
+ if (code != null) {
323
+ outcome.put("code", code);
324
+ }
325
+ notifyListeners("purchaseUpdated", outcome, true);
326
+ }
327
+
286
328
  private JSObject encodePurchase(Purchase purchase) {
287
329
  int state = purchase.getPurchaseState();
288
330
  if (state != Purchase.PurchaseState.PURCHASED
@@ -293,20 +335,32 @@ public class CapacitorPlayBillingPlugin extends Plugin {
293
335
  if (token == null || token.trim().isEmpty() || purchase.getProducts().isEmpty()) {
294
336
  return null;
295
337
  }
296
- JSArray productIds = new JSArray();
338
+ JSArray products = new JSArray();
297
339
  for (String productId : purchase.getProducts()) {
298
- productIds.put(productId);
340
+ products.put(productId);
299
341
  }
342
+ String purchaseState = state == Purchase.PurchaseState.PURCHASED ? "purchased" : "pending";
343
+ // Billing 9.1.0 Purchase getters under their own names, plus the 0.1 names
344
+ // (productIds, state, purchaseTimeMillis) until the next major version.
300
345
  JSObject encoded = new JSObject()
301
- .put("productIds", productIds)
346
+ .put("products", products)
347
+ .put("productIds", products)
302
348
  .put("purchaseToken", token)
303
- .put("state", state == Purchase.PurchaseState.PURCHASED ? "purchased" : "pending");
349
+ .put("purchaseState", purchaseState)
350
+ .put("state", purchaseState)
351
+ .put("purchaseTime", purchase.getPurchaseTime())
352
+ .put("quantity", purchase.getQuantity())
353
+ .put("isAcknowledged", purchase.isAcknowledged());
304
354
  if (purchase.getPurchaseTime() > 0) {
305
355
  encoded.put("purchaseTimeMillis", purchase.getPurchaseTime());
306
356
  }
307
357
  if (purchase.getOrderId() != null) {
308
358
  encoded.put("orderId", purchase.getOrderId());
309
359
  }
360
+ AccountIdentifiers accountIdentifiers = purchase.getAccountIdentifiers();
361
+ if (accountIdentifiers != null && accountIdentifiers.getObfuscatedAccountId() != null) {
362
+ encoded.put("obfuscatedAccountId", accountIdentifiers.getObfuscatedAccountId());
363
+ }
310
364
  return encoded;
311
365
  }
312
366
 
@@ -378,6 +432,11 @@ public class CapacitorPlayBillingPlugin extends Plugin {
378
432
  }
379
433
 
380
434
  private void rejectBilling(PluginCall call, BillingResult result) {
435
+ call.reject("Google Play Billing is unavailable (" + result.getResponseCode() + ").",
436
+ billingErrorCode(result));
437
+ }
438
+
439
+ private String billingErrorCode(BillingResult result) {
381
440
  int responseCode = result.getResponseCode();
382
441
  String code;
383
442
  switch (responseCode) {
@@ -406,8 +465,7 @@ public class CapacitorPlayBillingPlugin extends Plugin {
406
465
  default:
407
466
  code = "PLAY_BILLING_ERROR";
408
467
  }
409
- call.reject("Google Play Billing is unavailable (" + result.getResponseCode() + ").",
410
- code);
468
+ return code;
411
469
  }
412
470
 
413
471
  private void clearActivePurchase(PluginCall call) {
@@ -0,0 +1,68 @@
1
+ import { type CapacitorCommerceServiceProvider, type StoreCommerceProductMapping, type StoreCommerceProvider, type StoreSdkErrorCodeMap, type StoreSdkPurchase } from '@mpgd/adapter-capacitor';
2
+ import type { PlatformEvidenceEnvelope } from '@mpgd/platform';
3
+ import { type CommerceAuthorityCapabilities, type StoreId, type StorePurchaseAccountBinding } from '@mpgd/platform/commerce';
4
+ import type { CapacitorPlayBillingPlugin, PlayPurchase } from './definitions.js';
5
+ /** The evidence schema the Google Play verifier in `@mpgd/game-services` routes on. */
6
+ export declare const googlePlayProductPurchaseEvidenceSchema: 'google-play.product-purchase.v2';
7
+ /** RFC "Capabilities" profile row for `google-play`: the purchase token is the proof, bound to an obfuscated account id. */
8
+ export declare const playBillingCommerceAuthority: CommerceAuthorityCapabilities;
9
+ /** The default registry id of the Play Billing commerce v2 service provider. */
10
+ export declare const playBillingCommerceServiceProviderId: 'google-play-billing';
11
+ /**
12
+ * Evidence payload of a Play purchase: exactly `{ purchaseToken }`, which is
13
+ * what `createGooglePlayProductPurchaseBoundary` reads and what the v1
14
+ * provider emits, so v1 and v2 evidence verify through the same code path.
15
+ */
16
+ export declare function createPlayBillingEvidencePayload(purchase: Pick<StoreSdkPurchase, 'token'>): PlatformEvidenceEnvelope['payload'];
17
+ /** One catalog product sold through Play; `offerId` is the default one-time offer token. */
18
+ export interface PlayBillingCommerceProductMapping extends StoreCommerceProductMapping {
19
+ readonly kind: 'consumable' | 'durable';
20
+ }
21
+ export interface CreateCapacitorPlayBillingCommerceProviderInput {
22
+ readonly products: readonly PlayBillingCommerceProductMapping[];
23
+ /**
24
+ * Resolves the player's obfuscated account id (`{ kind:
25
+ * 'obfuscated-account-id', value }`) before a purchase opens. It must agree
26
+ * with the backend's `resolveObfuscatedAccountId`. A thrown lookup becomes
27
+ * `ACCOUNT_BINDING_REQUIRED` with `PLAY_BILLING_ACCOUNT_LOOKUP_FAILED` kept.
28
+ */
29
+ readonly accountBinding: () => Promise<StorePurchaseAccountBinding | undefined> | StorePurchaseAccountBinding | undefined;
30
+ /** Defaults to the registered `CapacitorPlayBilling` plugin. */
31
+ readonly plugin?: CapacitorPlayBillingPlugin;
32
+ /** Defaults to `Capacitor.getPlatform() === 'android'`. */
33
+ readonly isAndroid?: () => boolean;
34
+ /**
35
+ * Store id on purchases and in derived idempotency keys. Defaults to
36
+ * `google-play`, which keeps the `play-token-sha256:<hex>` key v1 ledgers
37
+ * use; a conformance harness sets the vector's fixture store id.
38
+ */
39
+ readonly store?: StoreId;
40
+ /** Extra plugin codes merged over `playBillingStoreSdkErrorCodeMap`. */
41
+ readonly mapError?: StoreSdkErrorCodeMap;
42
+ /** Diagnostics for plugin purchases left out of recovery (retired SKU, multi-line); see the SDK input. */
43
+ readonly onUnmappedPurchase?: (purchase: PlayPurchase) => void;
44
+ }
45
+ /**
46
+ * Commerce v2 `CommerceProvider` for Google Play: `createStoreCommerceProvider`
47
+ * over `createCapacitorPlayBillingStoreSdk`, with store `google-play`, evidence
48
+ * schema `google-play.product-purchase.v2` carrying `{ purchaseToken }`, and
49
+ * the `client-token` / `required` authority block. Finishing stays on the
50
+ * server (the backend acknowledges or consumes after the ledger grant), so
51
+ * the provider has no `finishTransaction` and no `restorePurchases`. It never
52
+ * grants.
53
+ */
54
+ export declare function createCapacitorPlayBillingCommerceProvider(input: CreateCapacitorPlayBillingCommerceProviderInput): StoreCommerceProvider;
55
+ export interface CreateCapacitorPlayBillingServiceProviderInput extends CreateCapacitorPlayBillingCommerceProviderInput {
56
+ /** Registry id; defaults to `google-play-billing`. */
57
+ readonly id?: string;
58
+ }
59
+ /**
60
+ * Registry entry for `createCapacitorPlatformGateway({ providers })`: the
61
+ * gateway serves the six `commerce.v2.*` bridge methods and exposes
62
+ * `gateway.commerceV2`, and v1 callers keep working through
63
+ * `toCommerceAdapter`. Only `nativeIap` is declared; Play sells no
64
+ * subscriptions in this release.
65
+ */
66
+ export declare function createCapacitorPlayBillingServiceProvider(input: CreateCapacitorPlayBillingServiceProviderInput): CapacitorCommerceServiceProvider & {
67
+ readonly commerce: StoreCommerceProvider;
68
+ };
@@ -0,0 +1,91 @@
1
+ import { createCommerceServiceProvider, createStoreCommerceProvider, } from '@mpgd/adapter-capacitor';
2
+ import { CommerceError, } from '@mpgd/platform/commerce';
3
+ import { createCapacitorPlayBillingStoreSdk, googlePlayStoreId, playBillingStoreSdkErrorCodeMap, } from './store-sdk.js';
4
+ /** The evidence schema the Google Play verifier in `@mpgd/game-services` routes on. */
5
+ export const googlePlayProductPurchaseEvidenceSchema = 'google-play.product-purchase.v2';
6
+ /** RFC "Capabilities" profile row for `google-play`: the purchase token is the proof, bound to an obfuscated account id. */
7
+ export const playBillingCommerceAuthority = Object.freeze({
8
+ evidence: 'client-token',
9
+ accountBinding: 'required',
10
+ });
11
+ /** The default registry id of the Play Billing commerce v2 service provider. */
12
+ export const playBillingCommerceServiceProviderId = 'google-play-billing';
13
+ /**
14
+ * Evidence payload of a Play purchase: exactly `{ purchaseToken }`, which is
15
+ * what `createGooglePlayProductPurchaseBoundary` reads and what the v1
16
+ * provider emits, so v1 and v2 evidence verify through the same code path.
17
+ */
18
+ export function createPlayBillingEvidencePayload(purchase) {
19
+ return { purchaseToken: purchase.token };
20
+ }
21
+ /**
22
+ * Commerce v2 `CommerceProvider` for Google Play: `createStoreCommerceProvider`
23
+ * over `createCapacitorPlayBillingStoreSdk`, with store `google-play`, evidence
24
+ * schema `google-play.product-purchase.v2` carrying `{ purchaseToken }`, and
25
+ * the `client-token` / `required` authority block. Finishing stays on the
26
+ * server (the backend acknowledges or consumes after the ledger grant), so
27
+ * the provider has no `finishTransaction` and no `restorePurchases`. It never
28
+ * grants.
29
+ */
30
+ export function createCapacitorPlayBillingCommerceProvider(input) {
31
+ const sdkProducts = [];
32
+ for (const mapping of input.products) {
33
+ if (mapping.kind !== 'consumable' && mapping.kind !== 'durable') {
34
+ throw new TypeError(`Play Billing sells one-time products only; ${String(mapping.logicalId)} is ${String(mapping.kind)}.`);
35
+ }
36
+ sdkProducts.push({
37
+ sku: mapping.storeSku,
38
+ kind: mapping.kind,
39
+ ...(mapping.offerId === undefined ? {} : { offerToken: mapping.offerId }),
40
+ });
41
+ }
42
+ const sdk = createCapacitorPlayBillingStoreSdk({
43
+ products: sdkProducts,
44
+ ...(input.plugin === undefined ? {} : { plugin: input.plugin }),
45
+ ...(input.isAndroid === undefined ? {} : { isAndroid: input.isAndroid }),
46
+ ...(input.onUnmappedPurchase === undefined
47
+ ? {}
48
+ : { onUnmappedPurchase: input.onUnmappedPurchase }),
49
+ });
50
+ const resolveAccountBinding = input.accountBinding;
51
+ return createStoreCommerceProvider({
52
+ sdk,
53
+ store: input.store ?? googlePlayStoreId,
54
+ products: input.products,
55
+ evidenceSchema: googlePlayProductPurchaseEvidenceSchema,
56
+ evidencePayload: createPlayBillingEvidencePayload,
57
+ accountBinding: async () => {
58
+ try {
59
+ return await resolveAccountBinding();
60
+ }
61
+ catch (error) {
62
+ throw new CommerceError('ACCOUNT_BINDING_REQUIRED', {
63
+ message: 'Play Billing could not resolve the obfuscated account id.',
64
+ storeCode: 'PLAY_BILLING_ACCOUNT_LOOKUP_FAILED',
65
+ cause: error,
66
+ });
67
+ }
68
+ },
69
+ authority: playBillingCommerceAuthority,
70
+ mapError: { ...playBillingStoreSdkErrorCodeMap, ...input.mapError },
71
+ });
72
+ }
73
+ /**
74
+ * Registry entry for `createCapacitorPlatformGateway({ providers })`: the
75
+ * gateway serves the six `commerce.v2.*` bridge methods and exposes
76
+ * `gateway.commerceV2`, and v1 callers keep working through
77
+ * `toCommerceAdapter`. Only `nativeIap` is declared; Play sells no
78
+ * subscriptions in this release.
79
+ */
80
+ export function createCapacitorPlayBillingServiceProvider(input) {
81
+ const { id, ...providerInput } = input;
82
+ const commerce = createCapacitorPlayBillingCommerceProvider(providerInput);
83
+ return {
84
+ ...createCommerceServiceProvider({
85
+ id: id ?? playBillingCommerceServiceProviderId,
86
+ commerce,
87
+ features: ['nativeIap'],
88
+ }),
89
+ commerce,
90
+ };
91
+ }
@@ -1,26 +1,68 @@
1
1
  import type { PluginListenerHandle } from '@capacitor/core';
2
2
  export type PlayPurchaseState = 'purchased' | 'pending';
3
+ export interface PlayOneTimeOffer {
4
+ readonly offerToken: string;
5
+ readonly formattedPrice: string;
6
+ readonly currencyCode: string;
7
+ /** `OneTimePurchaseOfferDetails.getPriceAmountMicros()`; absent on older plugin builds. */
8
+ readonly priceAmountMicros?: number;
9
+ }
3
10
  export interface PlayProduct {
4
11
  readonly productId: string;
5
12
  readonly title: string;
6
13
  readonly description: string;
7
14
  /** Explicit offer selection is required when Google returns multiple offers. */
8
- readonly offers: readonly {
9
- readonly offerToken: string;
10
- readonly formattedPrice: string;
11
- readonly currencyCode: string;
12
- }[];
15
+ readonly offers: readonly PlayOneTimeOffer[];
13
16
  }
17
+ /**
18
+ * One Play purchase as the plugin encodes `com.android.billingclient.api.Purchase`.
19
+ * `products`, `purchaseState`, `purchaseTime`, `quantity`, `isAcknowledged`, and
20
+ * `obfuscatedAccountId` follow the Billing Library 9.1.0 getters; `productIds`,
21
+ * `state`, and `purchaseTimeMillis` are the 0.1 names and carry the same values
22
+ * until the next major version.
23
+ */
14
24
  export interface PlayPurchase {
25
+ /** `Purchase.getProducts()`: the product ids this purchase covers (one for a single-line purchase). */
26
+ readonly products: readonly string[];
27
+ /** @deprecated Same value as `products`; removed in the next major version. */
15
28
  readonly productIds: readonly string[];
16
29
  readonly purchaseToken: string;
30
+ /** `Purchase.getOrderId()`; absent while Play has not assigned one (for example a pending purchase). */
17
31
  readonly orderId?: string;
32
+ /** `Purchase.getPurchaseTime()` in milliseconds since the epoch. */
33
+ readonly purchaseTime: number;
34
+ /** @deprecated Same value as `purchaseTime` when it is positive; removed in the next major version. */
18
35
  readonly purchaseTimeMillis?: number;
36
+ /** `Purchase.getPurchaseState()`; the plugin never reports `UNSPECIFIED_STATE`. */
37
+ readonly purchaseState: PlayPurchaseState;
38
+ /** @deprecated Same value as `purchaseState`; removed in the next major version. */
19
39
  readonly state: PlayPurchaseState;
40
+ /**
41
+ * `Purchase.isAcknowledged()`. The plugin never acknowledges or consumes; the
42
+ * backend finalizer does after the ledger grant, so `false` on a purchased
43
+ * entry means the purchase is still unfinished.
44
+ */
45
+ readonly isAcknowledged: boolean;
46
+ /** `Purchase.getQuantity()`; 1 unless multi-quantity purchases are enabled in Play Console. */
47
+ readonly quantity: number;
48
+ /** `Purchase.getAccountIdentifiers().getObfuscatedAccountId()` when the purchase flow set one. */
49
+ readonly obfuscatedAccountId?: string;
20
50
  }
51
+ /**
52
+ * Result of a `purchase` call (`purchased`, `pending`, or `cancelled`) and
53
+ * payload of every `purchaseUpdated` event. A `purchase` call never resolves
54
+ * `failed`: it rejects with the plugin code instead. On `purchaseUpdated`, a
55
+ * `cancelled` or `failed` outcome without a `purchase` is a terminal result
56
+ * that arrived after the `purchase` call for `productId` already timed out
57
+ * (`PLAY_BILLING_TIMEOUT`), so the attempt the timeout left open can close.
58
+ */
21
59
  export interface PlayPurchaseOutcome {
22
- readonly status: 'purchased' | 'pending' | 'cancelled';
60
+ readonly status: 'purchased' | 'pending' | 'cancelled' | 'failed';
23
61
  readonly purchase?: PlayPurchase;
62
+ /** The timed-out product a late `cancelled` or `failed` result belongs to. */
63
+ readonly productId?: string;
64
+ /** The `PLAY_BILLING_*` code of a late `failed` result. */
65
+ readonly code?: string;
24
66
  }
25
67
  export interface CapacitorPlayBillingPlugin {
26
68
  getProducts(input: {
@@ -33,9 +75,20 @@ export interface CapacitorPlayBillingPlugin {
33
75
  readonly offerToken?: string;
34
76
  readonly obfuscatedAccountId: string;
35
77
  }): Promise<PlayPurchaseOutcome>;
36
- /** Requeries owned, unconsumed purchases after restart or missed callbacks. */
78
+ /**
79
+ * Requeries every purchased or pending in-app purchase Play still reports
80
+ * for this user and device (`queryPurchasesAsync`): unconsumed consumables,
81
+ * acknowledged and unacknowledged durables, and pending purchases. The
82
+ * `isAcknowledged` flag separates finished durables from unfinished ones.
83
+ */
37
84
  getPurchases(): Promise<{
38
85
  readonly purchases: readonly PlayPurchase[];
39
86
  }>;
87
+ /**
88
+ * Every purchase update Play delivers (`PurchasesUpdatedListener`), including
89
+ * one that completes after the `purchase` call timed out or the app
90
+ * restarted. Updates delivered before a listener attaches are retained
91
+ * until the first listener consumes them.
92
+ */
40
93
  addListener(eventName: 'purchaseUpdated', listener: (event: PlayPurchaseOutcome) => void): Promise<PluginListenerHandle>;
41
94
  }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export * from './commerce-provider.js';
1
2
  export * from './definitions.js';
2
3
  export * from './plugin.js';
3
4
  export * from './provider.js';
5
+ export * from './store-sdk.js';
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ export * from './commerce-provider.js';
1
2
  export * from './definitions.js';
2
3
  export * from './plugin.js';
3
4
  export * from './provider.js';
5
+ export * from './store-sdk.js';
@@ -66,6 +66,12 @@ export interface PlayPurchaseRecoveryBackend {
66
66
  /**
67
67
  * Resubmit owned purchases after a callback was missed or the app restarted.
68
68
  * A native purchase is never credited locally; only the backend response grants it.
69
+ *
70
+ * @deprecated v1 recovery driver. Commerce v2 games register
71
+ * `createCapacitorPlayBillingServiceProvider` and let the single reconciler
72
+ * in `@mpgd/game-services` (PR C1) resolve unfinished purchases from
73
+ * `getAvailablePurchases` and `purchaseUpdated`; this driver is removed with
74
+ * the v1 adapter in the next major version.
69
75
  */
70
76
  export declare function recoverOwnedPlayPurchases(input: {
71
77
  readonly provider: CapacitorPlayBillingProvider;
package/dist/provider.js CHANGED
@@ -14,6 +14,12 @@ function isValidPurchaseToken(value) {
14
14
  /**
15
15
  * Resubmit owned purchases after a callback was missed or the app restarted.
16
16
  * A native purchase is never credited locally; only the backend response grants it.
17
+ *
18
+ * @deprecated v1 recovery driver. Commerce v2 games register
19
+ * `createCapacitorPlayBillingServiceProvider` and let the single reconciler
20
+ * in `@mpgd/game-services` (PR C1) resolve unfinished purchases from
21
+ * `getAvailablePurchases` and `purchaseUpdated`; this driver is removed with
22
+ * the v1 adapter in the next major version.
17
23
  */
18
24
  export async function recoverOwnedPlayPurchases(input) {
19
25
  if (input.playerId.trim() === '') {