@mpgd/capacitor-play-billing 0.2.3 → 0.3.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,15 +13,16 @@ 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
16
+ ## Commerce provider
17
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
18
+ Commerce ([RFC](../../docs/specs/commerce/commerce-v2-rfc.md)) is a `StoreSdk`
19
+ over this plugin and a `CommerceProvider` built from it with
20
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.
21
+ dependency). Register the service provider with the Capacitor gateway and it
22
+ serves the six `commerce.v2.*` bridge methods as `gateway.commerce`, reports
23
+ its capability block in `getCapabilities().commerce`, and declares no
24
+ `features` or `methods` of its own. The `@mpgd/game-services` reconciler
25
+ drives it; a scene never calls it.
25
26
 
26
27
  ```ts
27
28
  import { createCapacitorPlatformGateway } from '@mpgd/adapter-capacitor';
@@ -66,10 +67,9 @@ the plugin code. `getAvailablePurchases` is `getPurchases` filtered to
66
67
  unfinished entries: `purchased` with `isAcknowledged: false`, plus `pending`
67
68
  when `includePending` is set; each carries `token = purchaseToken` and
68
69
  `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)
70
+ Play assigns an order id, so the same purchase deduplicates in the ledger. A
71
+ purchased entry whose `isAcknowledged` flag is absent counts as unfinished so a
72
+ paid purchase is never dropped. A purchase for a SKU that is not in `products` (a retired product)
73
73
  or a multi-line purchase has no logical id for the reconciler, so it is left
74
74
  out of the list and of events and handed to the optional `onUnmappedPurchase`
75
75
  diagnostics hook instead; the backend finalizer still sees it through the
@@ -79,8 +79,8 @@ purchase without an order id waits for the token digest, and later updates
79
79
  queue behind it), and a `cancelled` or `failed` update without a purchase is
80
80
  the plugin's late terminal result for a purchase call that already timed out
81
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).
82
+ the attempt the timeout left open closes. `getAvailability` reports the Android
83
+ check, offer configuration, and service state.
84
84
  `playBillingStoreSdkErrorCodeMap` extends `defaultStoreSdkErrorCodeMap` with
85
85
  every other `PLAY_BILLING_*` code (`BUSY` is `IN_PROGRESS`, `OFFER_REQUIRED`
86
86
  and the invalid-product codes are `CONFIGURATION_REQUIRED`,
@@ -93,26 +93,32 @@ the attempt from `getAvailablePurchases` without reopening the store UI.
93
93
  `createCapacitorPlayBillingCommerceProvider({ products, accountBinding,
94
94
  plugin?, isAndroid?, store?, mapError? })` composes the provider: evidence
95
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
96
+ which `createGooglePlayProductPurchaseBoundary` verifies; authority
98
97
  `{ evidence: 'client-token', accountBinding: 'required' }`; no
99
98
  `finishTransaction` and no `restorePurchases`. A thrown `accountBinding`
100
99
  lookup is `ACCOUNT_BINDING_REQUIRED` with `PLAY_BILLING_ACCOUNT_LOOKUP_FAILED`
101
100
  kept as the store code. `store` defaults to `google-play`; only a conformance
102
101
  harness overrides it, because the derived idempotency key follows the store
103
- id. The conformance test runs every store vector whose profile the Play
102
+ id. `createPlayBillingCommerceCapabilities(availability)` (from
103
+ `src/commerce-profile.ts`, which imports types only and no Capacitor code)
104
+ returns the full `CommerceCapabilities` block this provider declares while the
105
+ store reports `availability`, so a validator or server can read the profile
106
+ without loading the plugin; a unit test keeps it equal to
107
+ `provider.getCapabilities()`. The conformance test runs every store vector whose profile the Play
104
108
  capabilities can serve (`purchase-server-finish`) against a fake plugin,
105
109
  directly and through the gateway bridge path, and lists the skipped vectors
106
110
  with their reason: the client-finish, host-finish, adapter-settled, and
107
111
  server-push profiles need a finish owner or evidence authority Play does not
108
112
  have.
109
113
 
110
- The plugin surface gained the Billing Library 9.1.0 `Purchase` getters on
111
- every purchase (`getPurchases()`, `purchase()`, and `purchaseUpdated`):
114
+ Every purchase the plugin reports (`getPurchases()`, `purchase()`, and
115
+ `purchaseUpdated`) carries the Billing Library 9.1.0 `Purchase` getters
112
116
  `products`, `purchaseState`, `purchaseTime`, `quantity`, `isAcknowledged`,
113
117
  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
118
+ `productIds`, `state`, and `purchaseTimeMillis` were removed from the payload
119
+ and the type. The TypeScript readers still accept them, so a web bundle that
120
+ reaches a 0.1 native build first (a live update) keeps paid purchases
121
+ readable; run `cap sync` after upgrading all the same. `purchaseUpdated` is retained
116
122
  until a listener consumes it, so a purchase that completes after the
117
123
  `purchase` call timed out, or before the web view attached its listener,
118
124
  still reaches the provider, and a cancel or non-OK result that arrives after
@@ -143,42 +149,25 @@ A build whose shell lacks the native module still runs: the plugin answers
143
149
  Capacitor's `UNIMPLEMENTED`, which the SDK reports as
144
150
  `configuration-required`, so no purchase UI opens.
145
151
 
146
- ### Migrating from the v1 provider
147
-
148
- `createCapacitorPlayBillingProvider` and `recoverOwnedPlayPurchases` are
149
- unchanged and keep working; both providers can share the plugin. Games that
150
- move to v2 replace the v1 provider in `providers` with
151
- `createCapacitorPlayBillingServiceProvider`, turn `getObfuscatedAccountId`
152
- into an `accountBinding` resolver returning `{ kind: 'obfuscated-account-id',
153
- value }`, rename `id`/`storeId`/`type` to `logicalId`/`storeSku`/`kind`
154
- (`non_consumable` becomes `durable`) and `offerToken` to `offerId`, and stop
155
- calling `recoverOwnedPlayPurchases`: it is deprecated in favour of the single
156
- reconciler in `@mpgd/game-services` (`createCommerceReconciler`, behind
157
- `createGameServicesClient` and `monetizationRecovery.reconcile()` whenever
158
- `gateway.commerceV2` exists), which reads `getAvailablePurchases` and
159
- `purchaseUpdated` from the provider and verifies each purchase under the same
160
- `play-token-sha256` key. The backend contract is unchanged, since the
161
- evidence payload is the same; `src/commerce-reconciler-play-smoke.test.ts`
162
- shows both paths reaching the same outcomes over the fake plugin.
163
-
164
- ## v1 provider
165
-
166
- The provider maps game logical product IDs to Play product IDs and requires a
167
- stable obfuscated account ID that agrees with the backend account resolver.
168
- When Play returns multiple eligible one-time offers, map an explicit
169
- `offerToken`; otherwise product lookup reports `PLAY_BILLING_OFFER_REQUIRED`
170
- instead of displaying a price for the wrong offer.
171
- `getOwnedPurchases()` returns provisional evidence for recovery after a missed
172
- callback or process restart. `recoverOwnedPlayPurchases({ provider, backend:
173
- backend.purchases, playerId })` submits completed owned purchases to the same
174
- authenticated backend using stable token-derived idempotency keys. Backend
175
- uncertainty stays pending for retry; `commerce.restore` deliberately returns
176
- no local entitlements. Pending Play purchases remain pending until Google
177
- reports purchased. The caller must not grant based on a native callback,
178
- `getOwnedPurchases()` result, or `commerce.restore` alone.
179
- If a launched purchase times out, is interrupted, or reports an already-owned
180
- item without a matching callback, the provider returns `pending`. Requery and
181
- recover owned purchases before offering another billing flow.
152
+ ### Migrating from v1
153
+
154
+ Commerce v2 PR C4 removed the v1 provider and its recovery driver.
155
+
156
+ | Removed | Replacement |
157
+ | --- | --- |
158
+ | `createCapacitorPlayBillingProvider`, `PlayBillingProviderOptions`, `CapacitorPlayBillingProvider`, `PlayBillingProductMapping`, `PlayBillingAvailability` | `createCapacitorPlayBillingServiceProvider` (registry entry) or `createCapacitorPlayBillingCommerceProvider`; rename `id`/`storeId`/`type` to `logicalId`/`storeSku`/`kind` (`non_consumable` becomes `durable`) and `offerToken` to `offerId`; `getObfuscatedAccountId` becomes an `accountBinding` resolver returning `{ kind: 'obfuscated-account-id', value }` |
159
+ | `recoverOwnedPlayPurchases`, `getOwnedPurchases`, `OwnedPlayPurchase`, `RecoveredPlayPurchase`, `PlayPurchaseVerification`, `PlayPurchaseRecoveryBackend` | the reconciler in `@mpgd/game-services` (`createCommerceReconciler`, behind `createGameServicesClient` and `monetizationRecovery.reconcile()`), which reads `getAvailablePurchases` and `purchaseUpdated` from the provider and verifies each purchase under the same `play-token-sha256` key; the backend contract is unchanged because the evidence payload is the same |
160
+ | `createGooglePlayTokenTransactionId` | `createGooglePlayTokenPurchaseKey` from `@mpgd/platform/commerce` (the same function) |
161
+ | `gateway.commerceV2`, the v1 `commerce.*` bridge methods | `gateway.commerce` and the `commerce.v2.*` methods |
162
+ | `features: ['nativeIap']` on the registry entry | none: the entry declares no `features` and its readiness is `getCapabilities().commerce` |
163
+ | `PlayPurchase.productIds`, `.state`, `.purchaseTimeMillis` (plugin payload and TypeScript type) | `products`, `purchaseState`, `purchaseTime` |
164
+
165
+ `src/commerce-reconciler-play-smoke.test.ts` shows the reconciler resubmitting
166
+ owned purchases over the fake plugin: a purchased token is verified once, a
167
+ pending purchase is never sent, a rejection is terminal, and an outage stays
168
+ retryable.
169
+
170
+ ## Native compile check
182
171
 
183
172
  The Kit reference app includes an opt-in Gradle compile target, without
184
173
  bundling Billing into the base app:
@@ -340,20 +340,14 @@ public class CapacitorPlayBillingPlugin extends Plugin {
340
340
  products.put(productId);
341
341
  }
342
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.
343
+ // Billing 9.1.0 Purchase getters under their own names.
345
344
  JSObject encoded = new JSObject()
346
345
  .put("products", products)
347
- .put("productIds", products)
348
346
  .put("purchaseToken", token)
349
347
  .put("purchaseState", purchaseState)
350
- .put("state", purchaseState)
351
348
  .put("purchaseTime", purchase.getPurchaseTime())
352
349
  .put("quantity", purchase.getQuantity())
353
350
  .put("isAcknowledged", purchase.isAcknowledged());
354
- if (purchase.getPurchaseTime() > 0) {
355
- encoded.put("purchaseTimeMillis", purchase.getPurchaseTime());
356
- }
357
351
  if (purchase.getOrderId() != null) {
358
352
  encoded.put("orderId", purchase.getOrderId());
359
353
  }
@@ -0,0 +1,24 @@
1
+ import type { StoreSdkCapabilities } from '@mpgd/adapter-capacitor';
2
+ import type { CommerceAuthorityCapabilities, CommerceCapabilities, PlatformProviderAvailability } from '@mpgd/platform';
3
+ /** The store id of the Play Billing `StoreSdk` (RFC "Capabilities", `google-play`). */
4
+ export declare const googlePlayStoreId: 'google-play';
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
+ /**
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 declare const playBillingStoreSdkCapabilities: StoreSdkCapabilities;
14
+ /** RFC "Capabilities" profile row for `google-play`: the purchase token is the proof, bound to an obfuscated account id. */
15
+ export declare const playBillingCommerceAuthority: CommerceAuthorityCapabilities;
16
+ /**
17
+ * The `CommerceCapabilities` block `createCapacitorPlayBillingCommerceProvider`
18
+ * declares while the store reports `availability`: composed from
19
+ * `playBillingStoreSdkCapabilities` and `playBillingCommerceAuthority` exactly
20
+ * as `createStoreCommerceProvider` composes it (the provider has no capability
21
+ * overrides, and the Play SDK has no `restorePurchases`). Returns a fresh object
22
+ * on every call and has no side effects.
23
+ */
24
+ export declare function createPlayBillingCommerceCapabilities(availability: PlatformProviderAvailability): CommerceCapabilities;
@@ -0,0 +1,51 @@
1
+ /** The store id of the Play Billing `StoreSdk` (RFC "Capabilities", `google-play`). */
2
+ export const googlePlayStoreId = 'google-play';
3
+ /** The evidence schema the Google Play verifier in `@mpgd/game-services` routes on. */
4
+ export const googlePlayProductPurchaseEvidenceSchema = 'google-play.product-purchase.v2';
5
+ /**
6
+ * What the Play Billing plugin supports (RFC "Capabilities" profile row for
7
+ * `google-play`): one-time consumables and durables, no subscriptions, no
8
+ * multi-quantity, pending purchases, no restore call, one-time offers, and a
9
+ * server-owned finish (the backend acknowledges or consumes after the grant).
10
+ */
11
+ export const playBillingStoreSdkCapabilities = Object.freeze({
12
+ purchase: Object.freeze({
13
+ consumable: true,
14
+ durable: true,
15
+ subscription: false,
16
+ quantity: false,
17
+ }),
18
+ offers: true,
19
+ pending: true,
20
+ restore: false,
21
+ finish: 'server',
22
+ });
23
+ /** RFC "Capabilities" profile row for `google-play`: the purchase token is the proof, bound to an obfuscated account id. */
24
+ export const playBillingCommerceAuthority = Object.freeze({
25
+ evidence: 'client-token',
26
+ accountBinding: 'required',
27
+ });
28
+ /**
29
+ * The `CommerceCapabilities` block `createCapacitorPlayBillingCommerceProvider`
30
+ * declares while the store reports `availability`: composed from
31
+ * `playBillingStoreSdkCapabilities` and `playBillingCommerceAuthority` exactly
32
+ * as `createStoreCommerceProvider` composes it (the provider has no capability
33
+ * overrides, and the Play SDK has no `restorePurchases`). Returns a fresh object
34
+ * on every call and has no side effects.
35
+ */
36
+ export function createPlayBillingCommerceCapabilities(availability) {
37
+ const sdk = playBillingStoreSdkCapabilities;
38
+ return {
39
+ purchase: { ...sdk.purchase },
40
+ catalog: { products: true, offers: sdk.offers ?? false, localizedPrice: true },
41
+ lifecycle: {
42
+ availablePurchases: true,
43
+ pending: sdk.pending,
44
+ restore: sdk.restore,
45
+ events: true,
46
+ finish: sdk.finish,
47
+ },
48
+ authority: { ...playBillingCommerceAuthority },
49
+ availability,
50
+ };
51
+ }
@@ -1,17 +1,14 @@
1
1
  import { type CapacitorCommerceServiceProvider, type StoreCommerceProductMapping, type StoreCommerceProvider, type StoreSdkErrorCodeMap, type StoreSdkPurchase } from '@mpgd/adapter-capacitor';
2
2
  import type { PlatformEvidenceEnvelope } from '@mpgd/platform';
3
- import { type CommerceAuthorityCapabilities, type StoreId, type StorePurchaseAccountBinding } from '@mpgd/platform/commerce';
3
+ import { type StoreId, type StorePurchaseAccountBinding } from '@mpgd/platform/commerce';
4
+ import { googlePlayProductPurchaseEvidenceSchema, playBillingCommerceAuthority } from './commerce-profile.js';
4
5
  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;
6
+ export { googlePlayProductPurchaseEvidenceSchema, playBillingCommerceAuthority };
9
7
  /** The default registry id of the Play Billing commerce v2 service provider. */
10
8
  export declare const playBillingCommerceServiceProviderId: 'google-play-billing';
11
9
  /**
12
10
  * 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.
11
+ * what `createGooglePlayProductPurchaseBoundary` reads.
15
12
  */
16
13
  export declare function createPlayBillingEvidencePayload(purchase: Pick<StoreSdkPurchase, 'token'>): PlatformEvidenceEnvelope['payload'];
17
14
  /** One catalog product sold through Play; `offerId` is the default one-time offer token. */
@@ -33,8 +30,8 @@ export interface CreateCapacitorPlayBillingCommerceProviderInput {
33
30
  readonly isAndroid?: () => boolean;
34
31
  /**
35
32
  * 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.
33
+ * `google-play`, which keeps the `play-token-sha256:<hex>` key existing ledgers
34
+ * hold; a conformance harness sets the vector's fixture store id.
38
35
  */
39
36
  readonly store?: StoreId;
40
37
  /** Extra plugin codes merged over `playBillingStoreSdkErrorCodeMap`. */
@@ -58,10 +55,9 @@ export interface CreateCapacitorPlayBillingServiceProviderInput extends CreateCa
58
55
  }
59
56
  /**
60
57
  * 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.
58
+ * gateway serves the six `commerce.v2.*` bridge methods, exposes the provider
59
+ * as `gateway.commerce`, and reports its capability block (Play sells no
60
+ * subscriptions in this release).
65
61
  */
66
62
  export declare function createCapacitorPlayBillingServiceProvider(input: CreateCapacitorPlayBillingServiceProviderInput): CapacitorCommerceServiceProvider & {
67
63
  readonly commerce: StoreCommerceProvider;
@@ -1,19 +1,15 @@
1
1
  import { createCommerceServiceProvider, createStoreCommerceProvider, } from '@mpgd/adapter-capacitor';
2
2
  import { CommerceError, } from '@mpgd/platform/commerce';
3
+ import { googlePlayProductPurchaseEvidenceSchema, playBillingCommerceAuthority, } from './commerce-profile.js';
3
4
  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
- });
5
+ // The profile constants live in the SDK-free `commerce-profile.ts` so a tools
6
+ // project can read them without loading Capacitor; they stay exported here.
7
+ export { googlePlayProductPurchaseEvidenceSchema, playBillingCommerceAuthority };
11
8
  /** The default registry id of the Play Billing commerce v2 service provider. */
12
9
  export const playBillingCommerceServiceProviderId = 'google-play-billing';
13
10
  /**
14
11
  * 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.
12
+ * what `createGooglePlayProductPurchaseBoundary` reads.
17
13
  */
18
14
  export function createPlayBillingEvidencePayload(purchase) {
19
15
  return { purchaseToken: purchase.token };
@@ -72,10 +68,9 @@ export function createCapacitorPlayBillingCommerceProvider(input) {
72
68
  }
73
69
  /**
74
70
  * 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.
71
+ * gateway serves the six `commerce.v2.*` bridge methods, exposes the provider
72
+ * as `gateway.commerce`, and reports its capability block (Play sells no
73
+ * subscriptions in this release).
79
74
  */
80
75
  export function createCapacitorPlayBillingServiceProvider(input) {
81
76
  const { id, ...providerInput } = input;
@@ -84,7 +79,6 @@ export function createCapacitorPlayBillingServiceProvider(input) {
84
79
  ...createCommerceServiceProvider({
85
80
  id: id ?? playBillingCommerceServiceProviderId,
86
81
  commerce,
87
- features: ['nativeIap'],
88
82
  }),
89
83
  commerce,
90
84
  };
@@ -17,26 +17,18 @@ export interface PlayProduct {
17
17
  /**
18
18
  * One Play purchase as the plugin encodes `com.android.billingclient.api.Purchase`.
19
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.
20
+ * `obfuscatedAccountId` follow the Billing Library 9.1.0 getters.
23
21
  */
24
22
  export interface PlayPurchase {
25
23
  /** `Purchase.getProducts()`: the product ids this purchase covers (one for a single-line purchase). */
26
24
  readonly products: readonly string[];
27
- /** @deprecated Same value as `products`; removed in the next major version. */
28
- readonly productIds: readonly string[];
29
25
  readonly purchaseToken: string;
30
26
  /** `Purchase.getOrderId()`; absent while Play has not assigned one (for example a pending purchase). */
31
27
  readonly orderId?: string;
32
28
  /** `Purchase.getPurchaseTime()` in milliseconds since the epoch. */
33
29
  readonly purchaseTime: number;
34
- /** @deprecated Same value as `purchaseTime` when it is positive; removed in the next major version. */
35
- readonly purchaseTimeMillis?: number;
36
30
  /** `Purchase.getPurchaseState()`; the plugin never reports `UNSPECIFIED_STATE`. */
37
31
  readonly purchaseState: PlayPurchaseState;
38
- /** @deprecated Same value as `purchaseState`; removed in the next major version. */
39
- readonly state: PlayPurchaseState;
40
32
  /**
41
33
  * `Purchase.isAcknowledged()`. The plugin never acknowledges or consumes; the
42
34
  * backend finalizer does after the ledger grant, so `false` on a purchased
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
+ export { createPlayBillingCommerceCapabilities } from './commerce-profile.js';
1
2
  export * from './commerce-provider.js';
2
3
  export * from './definitions.js';
3
4
  export * from './plugin.js';
4
- export * from './provider.js';
5
5
  export * from './store-sdk.js';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
+ export { createPlayBillingCommerceCapabilities } from './commerce-profile.js';
1
2
  export * from './commerce-provider.js';
2
3
  export * from './definitions.js';
3
4
  export * from './plugin.js';
4
- export * from './provider.js';
5
5
  export * from './store-sdk.js';
@@ -1,15 +1,8 @@
1
1
  import type { PlatformProviderAvailability } from '@mpgd/platform';
2
- import { type StoreSdk, type StoreSdkCapabilities, type StoreSdkError, type StoreSdkErrorCodeMap, type StoreSdkPurchase } from '@mpgd/adapter-capacitor';
2
+ import { type StoreSdk, type StoreSdkError, type StoreSdkErrorCodeMap, type StoreSdkPurchase } from '@mpgd/adapter-capacitor';
3
3
  import type { CapacitorPlayBillingPlugin, PlayPurchase } from './definitions.js';
4
- /** The store id of the Play Billing `StoreSdk` (RFC "Capabilities", `google-play`). */
5
- export declare const googlePlayStoreId: 'google-play';
6
- /**
7
- * What the Play Billing plugin supports (RFC "Capabilities" profile row for
8
- * `google-play`): one-time consumables and durables, no subscriptions, no
9
- * multi-quantity, pending purchases, no restore call, one-time offers, and a
10
- * server-owned finish (the backend acknowledges or consumes after the grant).
11
- */
12
- export declare const playBillingStoreSdkCapabilities: StoreSdkCapabilities;
4
+ import { googlePlayStoreId, playBillingStoreSdkCapabilities } from './commerce-profile.js';
5
+ export { googlePlayStoreId, playBillingStoreSdkCapabilities };
13
6
  /**
14
7
  * `defaultStoreSdkErrorCodeMap` plus every `PLAY_BILLING_*` code the plugin or
15
8
  * this SDK emits that the default map does not already normalize.
@@ -71,19 +64,18 @@ export declare class PlayBillingStoreSdkError extends Error implements StoreSdkE
71
64
  * provider normalizes (`PLAY_BILLING_TIMEOUT`, `PLAY_BILLING_INTERRUPTED`, and
72
65
  * `PLAY_BILLING_EMPTY_PURCHASE` become `OUTCOME_UNKNOWN` and stay recoverable).
73
66
  * `transactionId` is the order id or, until Play assigns one, the token key
74
- * `play-token-sha256:<hex>` v1 ledgers already use. Nothing here grants, and
67
+ * `play-token-sha256:<hex>` existing ledgers already use. Nothing here grants, and
75
68
  * nothing here acknowledges or consumes: the backend finalizer owns both.
76
69
  */
77
70
  export declare function createCapacitorPlayBillingStoreSdk(input: CreateCapacitorPlayBillingStoreSdkInput): CapacitorPlayBillingStoreSdk;
78
- /** Validates a purchase token exactly as the v1 provider does. */
71
+ /** Validates a Play purchase token: non-empty, at most 4096 characters, no padding or control characters. */
79
72
  export declare function isValidPlayPurchaseToken(value: unknown): value is string;
80
73
  /**
81
74
  * `StoreSdkPurchase` of one plugin purchase: `token` is the purchase token,
82
75
  * `transactionId` is the order id or the token key when Play has not assigned
83
- * one yet (the v1 rule, so ledgers written by v1 deduplicate), `purchasedAt`
76
+ * one yet (so a purchase deduplicates in the ledger before and after Play assigns an order), `purchasedAt`
84
77
  * is `purchaseTime` as ISO 8601 when it is a valid instant, and the account
85
78
  * binding is the obfuscated account id the purchase flow recorded. Throws
86
79
  * `PLAY_BILLING_EVIDENCE_INVALID` for a shape the plugin never produces.
87
80
  */
88
81
  export declare function toPlayBillingSdkPurchase(purchase: PlayPurchase): Promise<StoreSdkPurchase>;
89
- export {};
package/dist/store-sdk.js CHANGED
@@ -1,27 +1,11 @@
1
1
  import { Capacitor } from '@capacitor/core';
2
2
  import { defaultStoreSdkErrorCodeMap, } from '@mpgd/adapter-capacitor';
3
3
  import { createGooglePlayTokenPurchaseKey } from '@mpgd/platform/commerce';
4
+ import { googlePlayStoreId, playBillingStoreSdkCapabilities } from './commerce-profile.js';
4
5
  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
- });
6
+ // The profile constants live in the SDK-free `commerce-profile.ts` so a tools
7
+ // project can read them without loading Capacitor; they stay exported here.
8
+ export { googlePlayStoreId, playBillingStoreSdkCapabilities };
25
9
  /**
26
10
  * `defaultStoreSdkErrorCodeMap` plus every `PLAY_BILLING_*` code the plugin or
27
11
  * this SDK emits that the default map does not already normalize.
@@ -84,7 +68,7 @@ export class PlayBillingStoreSdkError extends Error {
84
68
  * provider normalizes (`PLAY_BILLING_TIMEOUT`, `PLAY_BILLING_INTERRUPTED`, and
85
69
  * `PLAY_BILLING_EMPTY_PURCHASE` become `OUTCOME_UNKNOWN` and stay recoverable).
86
70
  * `transactionId` is the order id or, until Play assigns one, the token key
87
- * `play-token-sha256:<hex>` v1 ledgers already use. Nothing here grants, and
71
+ * `play-token-sha256:<hex>` existing ledgers already use. Nothing here grants, and
88
72
  * nothing here acknowledges or consumes: the backend finalizer owns both.
89
73
  */
90
74
  export function createCapacitorPlayBillingStoreSdk(input) {
@@ -416,7 +400,7 @@ export function createCapacitorPlayBillingStoreSdk(input) {
416
400
  };
417
401
  return sdk;
418
402
  }
419
- /** Validates a purchase token exactly as the v1 provider does. */
403
+ /** Validates a Play purchase token: non-empty, at most 4096 characters, no padding or control characters. */
420
404
  export function isValidPlayPurchaseToken(value) {
421
405
  return typeof value === 'string'
422
406
  && value.length > 0
@@ -427,7 +411,7 @@ export function isValidPlayPurchaseToken(value) {
427
411
  /**
428
412
  * `StoreSdkPurchase` of one plugin purchase: `token` is the purchase token,
429
413
  * `transactionId` is the order id or the token key when Play has not assigned
430
- * one yet (the v1 rule, so ledgers written by v1 deduplicate), `purchasedAt`
414
+ * one yet (so a purchase deduplicates in the ledger before and after Play assigns an order), `purchasedAt`
431
415
  * is `purchaseTime` as ISO 8601 when it is a valid instant, and the account
432
416
  * binding is the obfuscated account id the purchase flow recorded. Throws
433
417
  * `PLAY_BILLING_EVIDENCE_INVALID` for a shape the plugin never produces.
@@ -446,7 +430,7 @@ function convertPurchase(purchase) {
446
430
  if (!isRecord(purchase)) {
447
431
  throw evidenceInvalid('Play purchase is not an object.');
448
432
  }
449
- const state = purchase.purchaseState ?? purchase.state;
433
+ const state = purchaseStateOf(purchase);
450
434
  if (state !== 'pending' && state !== 'purchased') {
451
435
  throw evidenceInvalid('Play purchase state is invalid.');
452
436
  }
@@ -502,11 +486,22 @@ function skuOf(purchase) {
502
486
  const sku = Array.isArray(products) && products.length === 1 ? products[0] : undefined;
503
487
  return typeof sku === 'string' && productIdPattern.test(sku) ? sku : undefined;
504
488
  }
489
+ /**
490
+ * The purchase state under its Billing 9.1.0 name, or under the 0.1 name
491
+ * (`state`). The plugin's own native code sends only the current names; the
492
+ * 0.1 names (`state`, `productIds`, `purchaseTimeMillis`) are still read so a
493
+ * web bundle shipped ahead of its native binary, for example by a live
494
+ * update onto a 0.1 build, keeps every paid purchase readable and
495
+ * recoverable instead of rejecting it as invalid evidence.
496
+ */
497
+ function purchaseStateOf(purchase) {
498
+ return purchase.purchaseState ?? purchase.state;
499
+ }
505
500
  function isUnfinished(purchase, includePending) {
506
- const state = purchase.purchaseState ?? purchase.state;
501
+ const state = purchaseStateOf(purchase);
507
502
  if (state === 'purchased') {
508
503
  // A 0.1 native build reports no flag; an absent flag is unfinished so a
509
- // paid purchase is never dropped from recovery during a staged upgrade.
504
+ // paid purchase is never dropped from recovery.
510
505
  return purchase.isAcknowledged !== true;
511
506
  }
512
507
  return state === 'pending' && includePending;
@@ -519,7 +514,7 @@ function assertOutcomePurchase(outcome, expectedSku, expectedState) {
519
514
  if (skuOf(purchase) !== expectedSku) {
520
515
  throw evidenceInvalid('Play purchase did not match the requested product.');
521
516
  }
522
- if ((purchase.purchaseState ?? purchase.state) !== expectedState) {
517
+ if (purchaseStateOf(purchase) !== expectedState) {
523
518
  throw evidenceInvalid('Play purchase state is inconsistent.');
524
519
  }
525
520
  return purchase;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/capacitor-play-billing",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Opt-in Android Google Play Billing purchase collector for mpgd Capacitor games.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,9 +38,9 @@
38
38
  ],
39
39
  "peerDependencies": {
40
40
  "@capacitor/core": "^8.5.1",
41
- "@mpgd/adapter-capacitor": "^0.9.3",
42
- "@mpgd/bridge": "^0.12.3",
43
- "@mpgd/platform": "^0.22.0"
41
+ "@mpgd/adapter-capacitor": "^0.10.0",
42
+ "@mpgd/bridge": "^0.13.0",
43
+ "@mpgd/platform": "^0.23.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@capacitor/core": "8.5.2",
@@ -48,10 +48,10 @@
48
48
  "ttsc": "0.30.4",
49
49
  "typescript": "7.0.2",
50
50
  "vitest": "^4.0.0",
51
- "@mpgd/adapter-capacitor": "0.9.3",
52
- "@mpgd/bridge": "0.12.3",
53
- "@mpgd/platform": "0.22.0",
54
- "@mpgd/game-services": "0.23.0"
51
+ "@mpgd/game-services": "0.24.0",
52
+ "@mpgd/adapter-capacitor": "0.10.0",
53
+ "@mpgd/platform": "0.23.0",
54
+ "@mpgd/bridge": "0.13.0"
55
55
  },
56
56
  "main": "./dist/index.js",
57
57
  "types": "./dist/index.d.ts",
@@ -61,6 +61,6 @@
61
61
  "scripts": {
62
62
  "check": "ttsc --noEmit",
63
63
  "lint": "ttsc --noEmit",
64
- "test": "vitest run && cd ../.. && node tools/run-ttsx.mjs --project native-plugins/capacitor-play-billing/tsconfig.json native-plugins/capacitor-play-billing/src/provider.test.ts"
64
+ "test": "vitest run"
65
65
  }
66
66
  }
@@ -1,92 +0,0 @@
1
- import type { BridgeRequest, BridgeResponse } from '@mpgd/bridge';
2
- import type { PurchaseResult } from '@mpgd/platform';
3
- import type { CapacitorPlayBillingPlugin } from './definitions.js';
4
- export interface PlayBillingProductMapping {
5
- readonly id: string;
6
- readonly type: 'consumable' | 'non_consumable';
7
- readonly storeId: string;
8
- /** Required when Play lists multiple eligible purchase options or offers. */
9
- readonly offerToken?: string;
10
- }
11
- export interface PlayBillingProviderOptions {
12
- readonly products: readonly PlayBillingProductMapping[];
13
- /** Must agree with the server's resolveObfuscatedAccountId mapping. */
14
- readonly getObfuscatedAccountId: () => Promise<string> | string;
15
- readonly sdk?: Pick<CapacitorPlayBillingPlugin, 'getProducts' | 'purchase' | 'getPurchases'>;
16
- readonly isAndroid?: () => boolean;
17
- }
18
- export interface OwnedPlayPurchase {
19
- readonly productId: string;
20
- readonly result: PurchaseResult;
21
- readonly purchasedAt?: string;
22
- }
23
- export type PlayBillingAvailability = 'available' | 'configuration-required' | 'temporarily-unavailable' | 'unsupported';
24
- export interface CapacitorPlayBillingProvider {
25
- readonly id: 'google-play-billing';
26
- readonly features: readonly ['nativeIap'];
27
- readonly methods: readonly [
28
- 'commerce.getProducts',
29
- 'commerce.purchase',
30
- 'commerce.restore',
31
- 'commerce.getEntitlements'
32
- ];
33
- readonly bridge: {
34
- request(input: BridgeRequest): Promise<BridgeResponse>;
35
- };
36
- getAvailability(): Promise<Readonly<{
37
- nativeIap: PlayBillingAvailability;
38
- }>>;
39
- /** Requery owned, non-consumed transactions for game-owned backend recovery. */
40
- getOwnedPurchases(): Promise<readonly OwnedPlayPurchase[]>;
41
- }
42
- export interface RecoveredPlayPurchase {
43
- readonly productId: string;
44
- readonly status: 'granted' | 'pending' | 'rejected';
45
- readonly verification?: PlayPurchaseVerification;
46
- }
47
- export interface PlayPurchaseVerification {
48
- readonly verified: boolean;
49
- readonly disposition?: 'pending' | 'rejected';
50
- readonly alreadyProcessed: boolean;
51
- readonly ledgerEntryId?: string;
52
- readonly reason?: string;
53
- }
54
- export interface PlayPurchaseRecoveryBackend {
55
- verifyPurchase(input: {
56
- readonly target: 'android';
57
- readonly deploymentTarget?: string;
58
- readonly playerId: string;
59
- readonly productId: string;
60
- readonly platformTransactionId: string;
61
- readonly idempotencyKey: string;
62
- readonly purchasedAt: string;
63
- readonly evidence: NonNullable<PurchaseResult['evidence']>;
64
- }): Promise<PlayPurchaseVerification>;
65
- }
66
- /**
67
- * Resubmit owned purchases after a callback was missed or the app restarted.
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.
75
- */
76
- export declare function recoverOwnedPlayPurchases(input: {
77
- readonly provider: CapacitorPlayBillingProvider;
78
- readonly backend: PlayPurchaseRecoveryBackend;
79
- readonly playerId: string;
80
- readonly deploymentTarget?: string;
81
- readonly now?: () => string;
82
- }): Promise<readonly RecoveredPlayPurchase[]>;
83
- export declare function createCapacitorPlayBillingProvider(options: PlayBillingProviderOptions): CapacitorPlayBillingProvider;
84
- /**
85
- * The server's token transaction ID, computed by the one shared helper so the
86
- * plugin and `@mpgd/game-services` can never drift.
87
- *
88
- * @deprecated Use `createGooglePlayTokenPurchaseKey` from `@mpgd/platform/commerce`;
89
- * this alias is the same function (same key, same `TypeError` for an invalid
90
- * token) and will be removed in the next major version.
91
- */
92
- export declare const createGooglePlayTokenTransactionId: (purchaseToken: string) => Promise<string>;
package/dist/provider.js DELETED
@@ -1,303 +0,0 @@
1
- import { Capacitor } from '@capacitor/core';
2
- import { createGooglePlayTokenPurchaseKey } from '@mpgd/platform/commerce';
3
- import { CapacitorPlayBilling } from './plugin.js';
4
- const googlePlayProductPurchaseEvidenceSchema = 'google-play.product-purchase.v2';
5
- class PlayPurchaseIntegrityError extends Error {
6
- }
7
- function isValidPurchaseToken(value) {
8
- return typeof value === 'string'
9
- && value.length > 0
10
- && value.length <= 4096
11
- && value.trim() === value
12
- && !/[\u0000-\u001f\u007f]/u.test(value);
13
- }
14
- /**
15
- * Resubmit owned purchases after a callback was missed or the app restarted.
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.
23
- */
24
- export async function recoverOwnedPlayPurchases(input) {
25
- if (input.playerId.trim() === '') {
26
- throw new TypeError('Recovery requires an authenticated player ID.');
27
- }
28
- const owned = await input.provider.getOwnedPurchases();
29
- return Promise.all(owned.map(async ({ productId, result, purchasedAt }) => {
30
- if (result.status === 'pending') {
31
- return { productId, status: 'pending' };
32
- }
33
- if (result.status !== 'completed' || result.transactionId === undefined
34
- || result.evidence === undefined) {
35
- return { productId, status: 'rejected' };
36
- }
37
- const token = result.evidence.payload.purchaseToken;
38
- if (typeof token !== 'string') {
39
- return { productId, status: 'rejected' };
40
- }
41
- try {
42
- const verification = await input.backend.verifyPurchase({
43
- target: 'android',
44
- ...(input.deploymentTarget === undefined ? {} : { deploymentTarget: input.deploymentTarget }),
45
- playerId: input.playerId,
46
- productId,
47
- platformTransactionId: result.transactionId,
48
- idempotencyKey: await createGooglePlayTokenPurchaseKey(token),
49
- purchasedAt: purchasedAt ?? input.now?.() ?? new Date().toISOString(),
50
- evidence: result.evidence,
51
- });
52
- return {
53
- productId,
54
- status: verification.verified ? 'granted' : (verification.disposition ?? 'pending'),
55
- verification,
56
- };
57
- }
58
- catch {
59
- // Network or backend uncertainty must remain retryable, never rejected.
60
- return { productId, status: 'pending' };
61
- }
62
- }));
63
- }
64
- export function createCapacitorPlayBillingProvider(options) {
65
- const sdk = options.sdk ?? CapacitorPlayBilling;
66
- const isAndroid = options.isAndroid ?? (() => Capacitor.getPlatform() === 'android');
67
- const byLogicalId = new Map();
68
- const byStoreId = new Map();
69
- for (const product of options.products) {
70
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(product.id)
71
- || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(product.storeId)
72
- || byLogicalId.has(product.id)
73
- || byStoreId.has(product.storeId)) {
74
- throw new TypeError('Play Billing product mappings must be unique and valid.');
75
- }
76
- byLogicalId.set(product.id, product);
77
- byStoreId.set(product.storeId, product);
78
- }
79
- if (byLogicalId.size === 0) {
80
- throw new TypeError('Play Billing requires at least one one-time product.');
81
- }
82
- if (byLogicalId.size > 100) {
83
- throw new TypeError('Play Billing supports at most 100 products per provider.');
84
- }
85
- async function getOwnedPurchases() {
86
- if (!isAndroid()) {
87
- throw new Error('Play Billing is only available on Android.');
88
- }
89
- const response = await sdk.getPurchases();
90
- const owned = [];
91
- for (const purchase of response.purchases) {
92
- if (purchase.productIds.length !== 1) {
93
- continue;
94
- }
95
- const mapping = byStoreId.get(purchase.productIds[0] ?? '');
96
- if (mapping !== undefined) {
97
- const timestamp = purchase.purchaseTimeMillis;
98
- const purchasedAt = typeof timestamp === 'number'
99
- && Number.isSafeInteger(timestamp)
100
- && timestamp > 0
101
- && timestamp <= 8_640_000_000_000_000
102
- ? new Date(timestamp).toISOString()
103
- : undefined;
104
- owned.push({
105
- productId: mapping.id,
106
- result: await convertPurchase(purchase),
107
- ...(purchasedAt === undefined ? {} : { purchasedAt }),
108
- });
109
- }
110
- }
111
- return owned;
112
- }
113
- function selectOffer(mapping, found) {
114
- if (mapping.offerToken === undefined) {
115
- return found.offers.length === 1 ? found.offers[0] : undefined;
116
- }
117
- return found.offers.find((item) => item.offerToken === mapping.offerToken);
118
- }
119
- async function request(input) {
120
- try {
121
- if (!isAndroid()) {
122
- return failure(input.id, 'PLAY_BILLING_ANDROID_REQUIRED');
123
- }
124
- switch (input.method) {
125
- case 'commerce.getProducts': {
126
- const response = await sdk.getProducts({
127
- productIds: [...byStoreId.keys()],
128
- });
129
- const products = [];
130
- for (const found of response.products) {
131
- const mapping = byStoreId.get(found.productId);
132
- if (mapping === undefined) {
133
- continue;
134
- }
135
- const offer = selectOffer(mapping, found);
136
- if (offer === undefined) {
137
- return failure(input.id, 'PLAY_BILLING_OFFER_REQUIRED');
138
- }
139
- products.push({
140
- id: mapping.id,
141
- type: mapping.type,
142
- title: found.title,
143
- description: found.description,
144
- price: {
145
- formatted: offer.formattedPrice,
146
- currencyCode: offer.currencyCode,
147
- },
148
- });
149
- }
150
- return success(input.id, products);
151
- }
152
- case 'commerce.purchase': {
153
- const payload = input.payload;
154
- const productId = isRecord(payload) ? payload.productId : undefined;
155
- const mapping = typeof productId === 'string' ? byLogicalId.get(productId) : undefined;
156
- if (mapping === undefined) {
157
- return failure(input.id, 'PLAY_BILLING_PRODUCT_UNKNOWN');
158
- }
159
- let obfuscatedAccountId;
160
- try {
161
- obfuscatedAccountId = await options.getObfuscatedAccountId();
162
- }
163
- catch {
164
- return failure(input.id, 'PLAY_BILLING_ACCOUNT_LOOKUP_FAILED');
165
- }
166
- if (!/^[A-Za-z0-9._-]{1,64}$/u.test(obfuscatedAccountId)) {
167
- return failure(input.id, 'PLAY_BILLING_ACCOUNT_REQUIRED');
168
- }
169
- const outcome = await sdk.purchase({
170
- productId: mapping.storeId,
171
- obfuscatedAccountId,
172
- ...(mapping.offerToken === undefined ? {} : { offerToken: mapping.offerToken }),
173
- });
174
- return success(input.id, await convertOutcome(outcome, mapping.storeId));
175
- }
176
- case 'commerce.restore':
177
- // Native ownership is not an authoritative entitlement. The game
178
- // must submit getOwnedPurchases() results to its backend separately.
179
- return success(input.id, { restoredEntitlements: [] });
180
- case 'commerce.getEntitlements':
181
- return success(input.id, []);
182
- default:
183
- return failure(input.id, 'PLAY_BILLING_METHOD_UNSUPPORTED');
184
- }
185
- }
186
- catch (error) {
187
- if (error instanceof PlayPurchaseIntegrityError) {
188
- return failure(input.id, 'PLAY_BILLING_EVIDENCE_INVALID');
189
- }
190
- const code = isRecord(error) ? error.code : undefined;
191
- if (typeof code === 'string' && /^PLAY_BILLING_[A-Z_]+$/u.test(code)) {
192
- if (input.method === 'commerce.purchase'
193
- && (code === 'PLAY_BILLING_TIMEOUT'
194
- || code === 'PLAY_BILLING_INTERRUPTED'
195
- || code === 'PLAY_BILLING_EMPTY_PURCHASE'
196
- || code === 'PLAY_BILLING_ALREADY_OWNED')) {
197
- // The purchase may have completed without an observed callback.
198
- // Requery owned purchases; never launch another flow blindly.
199
- return success(input.id, { status: 'pending', entitlementIds: [] });
200
- }
201
- const retryable = code === 'PLAY_BILLING_DISCONNECTED'
202
- || code === 'PLAY_BILLING_SERVICE_UNAVAILABLE'
203
- || code === 'PLAY_BILLING_NETWORK_ERROR'
204
- || code === 'PLAY_BILLING_TRANSIENT_ERROR';
205
- return failure(input.id, code, retryable);
206
- }
207
- return failure(input.id, 'PLAY_BILLING_UNAVAILABLE', true);
208
- }
209
- }
210
- return {
211
- id: 'google-play-billing',
212
- features: ['nativeIap'],
213
- methods: [
214
- 'commerce.getProducts',
215
- 'commerce.purchase',
216
- 'commerce.restore',
217
- 'commerce.getEntitlements',
218
- ],
219
- bridge: { request },
220
- async getAvailability() {
221
- if (!isAndroid()) {
222
- return { nativeIap: 'unsupported' };
223
- }
224
- try {
225
- const response = await sdk.getProducts({ productIds: [...byStoreId.keys()] });
226
- const hasPurchasableProduct = response.products.some((found) => {
227
- const mapping = byStoreId.get(found.productId);
228
- return mapping !== undefined && selectOffer(mapping, found) !== undefined;
229
- });
230
- return { nativeIap: hasPurchasableProduct ? 'available' : 'configuration-required' };
231
- }
232
- catch (error) {
233
- const code = isRecord(error) ? error.code : undefined;
234
- const configurationError = code === 'PLAY_BILLING_CONFIGURATION_ERROR'
235
- || code === 'PLAY_BILLING_PRODUCT_UNAVAILABLE'
236
- || code === 'PLAY_BILLING_OFFER_REQUIRED'
237
- || code === 'PLAY_BILLING_INVALID_PRODUCTS'
238
- || code === 'PLAY_BILLING_INVALID_PRODUCT';
239
- return { nativeIap: configurationError
240
- ? 'configuration-required'
241
- : 'temporarily-unavailable' };
242
- }
243
- },
244
- getOwnedPurchases,
245
- };
246
- }
247
- async function convertOutcome(outcome, expectedStoreId) {
248
- if (outcome.status === 'cancelled') {
249
- return { status: 'cancelled', entitlementIds: [] };
250
- }
251
- const purchase = outcome.purchase;
252
- if (purchase === undefined
253
- || purchase.productIds.length !== 1
254
- || purchase.productIds[0] !== expectedStoreId) {
255
- throw new PlayPurchaseIntegrityError('Play purchase did not match the requested product.');
256
- }
257
- if ((outcome.status === 'purchased' && purchase.state !== 'purchased')
258
- || (outcome.status === 'pending' && purchase.state !== 'pending')) {
259
- throw new PlayPurchaseIntegrityError('Play purchase state is inconsistent.');
260
- }
261
- return convertPurchase(purchase);
262
- }
263
- async function convertPurchase(purchase) {
264
- if (purchase.state !== 'pending' && purchase.state !== 'purchased') {
265
- throw new PlayPurchaseIntegrityError('Play purchase state is invalid.');
266
- }
267
- if (!isValidPurchaseToken(purchase.purchaseToken)) {
268
- throw new PlayPurchaseIntegrityError('Play purchase token is invalid.');
269
- }
270
- const evidence = {
271
- schema: googlePlayProductPurchaseEvidenceSchema,
272
- payload: { purchaseToken: purchase.purchaseToken },
273
- };
274
- if (purchase.state === 'pending') {
275
- return { status: 'pending', entitlementIds: [], evidence };
276
- }
277
- const transactionId = purchase.orderId?.trim()
278
- || await createGooglePlayTokenPurchaseKey(purchase.purchaseToken);
279
- return {
280
- status: 'completed',
281
- transactionId,
282
- entitlementIds: [],
283
- evidence,
284
- };
285
- }
286
- /**
287
- * The server's token transaction ID, computed by the one shared helper so the
288
- * plugin and `@mpgd/game-services` can never drift.
289
- *
290
- * @deprecated Use `createGooglePlayTokenPurchaseKey` from `@mpgd/platform/commerce`;
291
- * this alias is the same function (same key, same `TypeError` for an invalid
292
- * token) and will be removed in the next major version.
293
- */
294
- export const createGooglePlayTokenTransactionId = createGooglePlayTokenPurchaseKey;
295
- function success(id, data) {
296
- return { id, ok: true, data };
297
- }
298
- function failure(id, code, retryable = false) {
299
- return { id, ok: false, error: { code, message: code, retryable } };
300
- }
301
- function isRecord(value) {
302
- return typeof value === 'object' && value !== null && !Array.isArray(value);
303
- }