@mpgd/capacitor-storekit 0.6.0 → 0.7.1

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
@@ -59,14 +59,19 @@ over `getTransactions`, listing unfinished transactions and finished durables
59
59
  once (a current non-consumable entitlement the native side marks
60
60
  `unfinished: false` stays listed so a finish that raced the ledger grant can
61
61
  still be verified; the server ledger dedupes. A finished consumable is not
62
- listed. A `revokedAt` maps to `state: 'revoked'`, and one malformed
62
+ listed. A `revokedAt` maps to `state: 'revoked'`, and an unfinished revoked
63
+ transaction stays listed in that state so a refund before the finish can be
64
+ finished later (see Recovery); one malformed
63
65
  transaction is reported as a `purchaseError` without blocking its siblings);
64
- `finishTransaction({ transactionId, ledgerEntryId, isConsumable, retry? })`
66
+ `finishTransaction({ transactionId, ledgerEntryId, isConsumable, retry?, revoked? })`
65
67
  resolves the session app account token and finishes only a listed,
66
- non-revoked transaction bound to that token, or a durable of another token a
67
- restore shared to this player (see Recovery), and on a retry (`retry: true`)
68
+ non-revoked transaction bound to that token, or a durable of another token or
69
+ of no token a restore shared to this player (see Recovery), and on a retry (`retry: true`)
68
70
  answers `finished: true` without a native call for a consumable the list no
69
- longer carries (a lost acknowledgement, see Recovery);
71
+ longer carries (a lost acknowledgement, see Recovery); with `revoked: true`
72
+ (the SDK declares `acceptsRevokedFinish`; `ledgerEntryId` may then be
73
+ absent) it finishes only a listed, revoked consumable bound to that token (a
74
+ refund before the finish, see Recovery);
70
75
  `restorePurchases` over `sync`; `transactionUpdated` as `purchaseUpdated`;
71
76
  and an availability probe as `getAvailability`, probing the configured
72
77
  SKUs batch by batch until one is sold. Every purchase carries the
@@ -116,7 +121,9 @@ where `signedTransaction` is the same JWS as `StorePurchase.token`.
116
121
  its `signedTransactionVerifier` (authenticity and transaction id binding)
117
122
  and then, by default, decides the grant on the transaction's current status
118
123
  from the App Store Server API, so a JWS retained from before a refund never
119
- grants; see the backend guide for the opt-in offline-only mode. Subscription
124
+ grants; see the backend guide for the opt-in offline-only mode, which instead
125
+ refuses a refund the backend recorded from App Store Server Notifications
126
+ (its `revocations` registry is required). Subscription
120
127
  mappings are rejected at construction. `getAppAccountToken` also filters
121
128
  recovery and finishing to the current player, so it must keep answering
122
129
  while sales are paused: pause new sales with the optional `purchaseGate`
@@ -240,7 +247,7 @@ existing ledger grant, and calls `finishTransaction` only after a verified
240
247
  response with a ledger entry id. The StoreKit SDK and the native plugin both
241
248
  refuse to call `transaction.finish()` on a transaction whose app account
242
249
  token is missing or does not match the token they are given; the SDK gives the
243
- session's, except for a durable of another token (below). A caller who can read the token from the plugin can
250
+ session's, except for a durable of another token or of no token (below). A caller who can read the token from the plugin can
244
251
  still present it with a forged ledger entry id; that remaining hole needs a
245
252
  signed server finish capability. A listed, non-revoked durable StoreKit
246
253
  already finished (`unfinished: false`) answers `{ finished: true }` without a
@@ -256,8 +263,38 @@ durable takes nothing from the earlier guest: StoreKit keeps it in
256
263
  (`unfinished: false`). The native plugin is unchanged and still filters
257
264
  nothing by token on reads. A webview caller can therefore finish another
258
265
  token's unfinished durable, which, for the same reason, grants or removes
259
- nothing. A consumable of another token and a transaction bound to no token
260
- are never finished. Permanently rejected and account-mismatched
266
+ nothing.
267
+
268
+ A durable of the configured catalog still unfinished and bound to no token
269
+ (an offer code redeemed in the App Store, or another purchase made outside
270
+ the app, then restored; the backend's restore sharing grants it as
271
+ `APP_STORE_ACCOUNT_TOKEN_MISSING`) is finished with the explicit no-token
272
+ match: `finishTransaction({ transactionId, ledgerEntryId,
273
+ withoutAppAccountToken: true })`, with no `appAccountToken` field at all.
274
+ The native plugin then matches only a verified, non-revoked non-consumable
275
+ bound to no token; a missing token without the flag is still refused
276
+ (`STOREKIT_FINISH_REQUIRES_GRANT`), never read as "any token". The SDK sends
277
+ it only for a durable its catalog declares, for a record the reconciler
278
+ holds a ledger grant for, and only when the native build declares the match
279
+ through `getFinishSupport` (`{ withoutAppAccountToken: 1 }`). A native build
280
+ from before it answers that method with `UNIMPLEMENTED` (remembered) and would
281
+ refuse the call, so the SDK leaves the durable unfinished there and the record
282
+ keeps retrying `finishing`, as in earlier releases; the entitlement is
283
+ unaffected. Rebuild the app with this package's native code to finish it.
284
+ The catalog and ledger-grant conditions are the SDK's and the reconciler's;
285
+ the native match checks only the transaction itself and, like the token
286
+ match, is not a signed server capability, so a webview caller could finish
287
+ such a durable too, which grants or removes nothing.
288
+ Finishing takes nothing from anyone for the same reason as above:
289
+ StoreKit keeps a finished non-consumable in `Transaction.currentEntitlements`
290
+ ([Apple](https://developer.apple.com/documentation/storekit/transaction/currententitlements)).
291
+
292
+ A consumable of another token or of no token is never finished: the SDK
293
+ never admits it, the backend refuses one bound to no token for every
294
+ player (`APP_STORE_ACCOUNT_TOKEN_MISSING`), and it stays in
295
+ `Transaction.unfinished`, reported as an `account-mismatch` diagnostic on each
296
+ read. Subscriptions are never reported or finished by this plugin.
297
+ Permanently rejected and account-mismatched
261
298
  transactions are not finished: the client cannot prove content delivery for
262
299
  them, so persist that terminal result in the game-owned operation journal and
263
300
  do not run an automatic retry loop for it. An unknown backend result stays
@@ -305,6 +342,43 @@ until it is approved, so its `launched` record holds no ledger grant and is
305
342
  never finished; the approved transaction arrives through `transactionUpdated`
306
343
  and is verified and finished once.
307
344
 
345
+ A consumable the App Store refunds while it is still unfinished (the app
346
+ closed between the grant and the finish, or before the verify answered, and
347
+ the refund came first) stays in `Transaction.unfinished`, which StoreKit
348
+ redelivers at every launch until `finish()`, and the ordinary finish refuses
349
+ a revoked transaction. The SDK keeps it listed as `revoked` (its launch-time
350
+ `transactionUpdated` usually fires before the web listener attaches), and
351
+ the reconciler finishes it with `revoked: true` once the backend has
352
+ answered for it without a standing grant: the ledger reversed the grant
353
+ (App Store Server Notifications `REFUND`, read through
354
+ `getPurchaseLedgerStatus`), or the verify was refused as revoked
355
+ (`APP_STORE_TRANSACTION_REVOKED` or `LEDGER_GRANT_REVERSED`). A refunded
356
+ consumable of the session's own app account token that the journal holds no
357
+ record of is finished at once, without a verify. Nothing is granted or
358
+ verified again, and `REFUND_REVERSED` never re-grants. A revoked finish
359
+ names the reversed grant's ledger entry, or none when nothing was granted;
360
+ the native method requires a non-empty `ledgerEntryId`, so the SDK then
361
+ sends `revokedLedgerEntryIdPrefix` plus the transaction id, which names no
362
+ ledger entry and which the native code never reads. The SDK and the native
363
+ plugin (`StoreKitTransactionPolicy.finishableRevoked`, over
364
+ `Transaction.unfinished` only) finish only a revoked consumable bound to the
365
+ session token; a transaction still paid, a durable, another or no token,
366
+ and a malformed entry stay refused. So does one the list does not carry
367
+ (`finished: false`, no native call): the list cannot tell a consumable
368
+ already finished from one an early read does not carry yet, or from an
369
+ entry the native read skipped as unverifiable. The reconciler does not
370
+ count that answer as asked, so the next report asks again. Only revoked
371
+ consumables of the catalog are listed; a refunded durable is not. OpenIAP's finish takes the transaction by id and
372
+ does not check the revocation date. While the grant stands (a backend
373
+ without the ledger status read, or a refund not recorded yet) the
374
+ transaction stays unfinished, as before, and so does one the backend
375
+ refused for another reason. Version mixes keep the earlier behaviour: a
376
+ native build from before `revoked` ignores the flag and its ordinary gate
377
+ refuses the revoked transaction (`finished: false`, reported as a `finish`
378
+ failure and asked again on the next launch), an older adapter neither lists
379
+ revoked purchases nor forwards the flag, and an older reconciler never
380
+ sends it.
381
+
308
382
  ### Migrating from v1
309
383
 
310
384
  Commerce v2 PR C4 removed the v1 provider and its recovery driver.
@@ -58,6 +58,45 @@ export interface StoreKitPurchasePresentationEvent {
58
58
  export interface StoreKitPresentationSupport {
59
59
  readonly purchasePresentation: number;
60
60
  }
61
+ /**
62
+ * What `getFinishSupport` declares: `withoutAppAccountToken` is the protocol
63
+ * version of the explicit no-token finish, absent from a build before it.
64
+ */
65
+ export interface StoreKitFinishSupport {
66
+ readonly withoutAppAccountToken?: number;
67
+ }
68
+ /**
69
+ * One `finishTransaction` call: the session app account token, or explicitly
70
+ * none (`withoutAppAccountToken: true`, no token field at all), which matches
71
+ * only a non-consumable bound to no token. Never both, never neither.
72
+ */
73
+ export type StoreKitFinishTransactionInput = {
74
+ readonly transactionId: string;
75
+ readonly ledgerEntryId: string;
76
+ /** Session app account token; must match the verified transaction. */
77
+ readonly appAccountToken: string;
78
+ readonly withoutAppAccountToken?: never;
79
+ /**
80
+ * Finish a transaction StoreKit lists as revoked (refunded) instead:
81
+ * only an unfinished, revoked consumable bound to `appAccountToken`, and
82
+ * nothing else. Grants nothing; it stops StoreKit redelivering it. Never
83
+ * combined with `withoutAppAccountToken`. A native build from before the
84
+ * flag ignores it and refuses the revoked transaction (`finished: false`).
85
+ */
86
+ readonly revoked?: boolean;
87
+ } | {
88
+ readonly transactionId: string;
89
+ readonly ledgerEntryId: string;
90
+ readonly appAccountToken?: never;
91
+ readonly revoked?: never;
92
+ /**
93
+ * Finish a verified, non-revoked non-consumable bound to no app account
94
+ * token (bought or redeemed outside the app). Only a build whose
95
+ * `getFinishSupport` declares it accepts this; an earlier one rejects
96
+ * the call with `STOREKIT_FINISH_REQUIRES_GRANT`.
97
+ */
98
+ readonly withoutAppAccountToken: true;
99
+ };
61
100
  export interface CapacitorStoreKitPlugin {
62
101
  getProducts(input: {
63
102
  readonly productIds: readonly string[];
@@ -76,6 +115,13 @@ export interface CapacitorStoreKitPlugin {
76
115
  * it still type-checks, and then the SDK refuses purchases that need facts.
77
116
  */
78
117
  getPresentationSupport?(): Promise<StoreKitPresentationSupport>;
118
+ /**
119
+ * Declares the finish matches beyond the session token. A native build
120
+ * from before it answers `UNIMPLEMENTED`; optional so a plugin double
121
+ * without it still type-checks, and then the SDK never finishes a
122
+ * transaction bound to no token.
123
+ */
124
+ getFinishSupport?(): Promise<StoreKitFinishSupport>;
79
125
  /** Includes unfinished consumables and current non-consumable entitlements. */
80
126
  getTransactions(): Promise<{
81
127
  readonly transactions: readonly StoreKitTransaction[];
@@ -85,21 +131,21 @@ export interface CapacitorStoreKitPlugin {
85
131
  readonly synced: boolean;
86
132
  }>;
87
133
  /**
88
- * Call only after the authenticated backend confirms the ledger grant.
89
- * Finishing never grants. Native finishes only a verified, non-revoked,
90
- * one-time transaction whose app account token matches `appAccountToken`.
134
+ * Call only after the authenticated backend confirms the ledger grant
135
+ * (with `revoked`: after it answered for a refunded transaction without a
136
+ * standing grant). Finishing never grants. Native finishes only a
137
+ * verified, non-revoked, one-time transaction whose app account token
138
+ * matches `appAccountToken`, or, with `withoutAppAccountToken: true`, a
139
+ * verified, non-revoked non-consumable bound to no token, or, with
140
+ * `revoked: true`, only a revoked consumable bound to `appAccountToken`.
91
141
  * The StoreKit SDK injects the session token and applies the same match
92
142
  * before this call, including for `commerce.v2.finishTransaction`. Games
93
143
  * must not call this plugin method directly. The token match is not a
94
144
  * signed server capability: a caller who can read `getTransactions` can
95
- * still supply that token with a forged ledger entry id.
145
+ * still supply that token (or the no-token flag) with a forged ledger
146
+ * entry id.
96
147
  */
97
- finishTransaction(input: {
98
- readonly transactionId: string;
99
- readonly ledgerEntryId: string;
100
- /** Session app account token; must match the verified transaction. */
101
- readonly appAccountToken: string;
102
- }): Promise<{
148
+ finishTransaction(input: StoreKitFinishTransactionInput): Promise<{
103
149
  readonly finished: boolean;
104
150
  }>;
105
151
  addListener(eventName: 'transactionUpdated', listener: (event: StoreKitTransaction) => void): Promise<PluginListenerHandle>;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * A native support probe: asks the plugin once whether its build declares a
3
+ * feature, and answers every later caller from that answer. The rule matches
4
+ * the sheet-facts probe of `@mpgd/adapter-capacitor`
5
+ * (`createNativePurchasePresentation().supported()`), kept here so this
6
+ * package does not depend on that adapter's version for it:
7
+ *
8
+ * - `read` runs inside the promise chain, so a plugin proxy that throws on
9
+ * access is answered like a call that failed.
10
+ * - Any answer is passed to `declared` and remembered (a build without the
11
+ * method answers `undefined`).
12
+ * - `UNIMPLEMENTED` (a native build from before the method) is remembered as
13
+ * "not supported".
14
+ * - Any other failure answers "not supported" for this caller and is asked
15
+ * again by the next one.
16
+ */
17
+ export declare function probeNativeSupport(read: () => unknown, declared: (answer: unknown) => boolean): () => Promise<boolean>;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * A native support probe: asks the plugin once whether its build declares a
3
+ * feature, and answers every later caller from that answer. The rule matches
4
+ * the sheet-facts probe of `@mpgd/adapter-capacitor`
5
+ * (`createNativePurchasePresentation().supported()`), kept here so this
6
+ * package does not depend on that adapter's version for it:
7
+ *
8
+ * - `read` runs inside the promise chain, so a plugin proxy that throws on
9
+ * access is answered like a call that failed.
10
+ * - Any answer is passed to `declared` and remembered (a build without the
11
+ * method answers `undefined`).
12
+ * - `UNIMPLEMENTED` (a native build from before the method) is remembered as
13
+ * "not supported".
14
+ * - Any other failure answers "not supported" for this caller and is asked
15
+ * again by the next one.
16
+ */
17
+ export function probeNativeSupport(read, declared) {
18
+ let support;
19
+ return () => {
20
+ if (support !== undefined) {
21
+ return support;
22
+ }
23
+ const attempt = Promise.resolve()
24
+ .then(read)
25
+ .then(declared, (error) => {
26
+ if (!isRecord(error) || error.code !== 'UNIMPLEMENTED') {
27
+ support = undefined;
28
+ }
29
+ return false;
30
+ });
31
+ support = attempt;
32
+ return attempt;
33
+ };
34
+ }
35
+ function isRecord(value) {
36
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
37
+ }
@@ -5,7 +5,7 @@ import { storeKitStoreSdkCapabilities } from './commerce-profile.js';
5
5
  import { type StoreKitSdkPurchasePresentation } from './purchase-presentation.js';
6
6
  export type { StoreKitSdkPurchasePresentation, StoreKitSdkPurchasePresentationEvent, } from './purchase-presentation.js';
7
7
  /** The plugin surface the commerce v2 SDK drives; `CapacitorStoreKit` by default. */
8
- export type CapacitorStoreKitStoreSdkPlugin = Pick<CapacitorStoreKitPlugin, 'getProducts' | 'purchase' | 'getTransactions' | 'sync' | 'finishTransaction' | 'addListener' | 'getPresentationSupport'>;
8
+ export type CapacitorStoreKitStoreSdkPlugin = Pick<CapacitorStoreKitPlugin, 'getProducts' | 'purchase' | 'getTransactions' | 'sync' | 'finishTransaction' | 'addListener' | 'getPresentationSupport' | 'getFinishSupport'>;
9
9
  /**
10
10
  * The code of a purchase refused because the native plugin in the app binary
11
11
  * reports no sheet facts (a web bundle newer than its native build). The
@@ -102,6 +102,12 @@ export { storeKitStoreSdkCapabilities };
102
102
  export declare const storeKitStoreSdkErrorCodeMap: StoreSdkErrorCodeMap;
103
103
  /** The default table plus every StoreKit code; what the provider uses. */
104
104
  export declare const capacitorStoreKitErrorCodeMap: StoreSdkErrorCodeMap;
105
+ /**
106
+ * The `ledgerEntryId` the SDK sends the native plugin for a revoked finish
107
+ * that names no ledger entry (a refund of a purchase never granted). The
108
+ * native method requires the field on every call; it is not a ledger entry.
109
+ */
110
+ export declare const revokedLedgerEntryIdPrefix = "revoked-without-grant:";
105
111
  /** Normalizes a required `app-account-token` binding to the lowercase UUID StoreKit expects. */
106
112
  export declare function readAppAccountToken(binding: StorePurchaseAccountBinding | undefined): string;
107
113
  /** Maps a verified StoreKit transaction to the SDK purchase: the JWS is the token. */
package/dist/store-sdk.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Capacitor } from '@capacitor/core';
2
2
  import { defaultStoreSdkErrorCodeMap, } from '@mpgd/adapter-capacitor';
3
3
  import { appStoreStoreId, storeKitStoreSdkCapabilities } from './commerce-profile.js';
4
+ import { probeNativeSupport } from './native-support.js';
4
5
  import { CapacitorStoreKit } from './plugin.js';
5
6
  import { resolveStoreKitNativePurchasePresentation, } from './purchase-presentation.js';
6
7
  /**
@@ -266,13 +267,27 @@ export function createCapacitorStoreKitStoreSdk(input) {
266
267
  }
267
268
  return expected;
268
269
  };
270
+ /**
271
+ * Whether the native build declares the explicit no-token finish
272
+ * (`getFinishSupport`). A build from before it answers `UNIMPLEMENTED`,
273
+ * which is remembered; any other failure is asked again on the next
274
+ * finish, and both answer "not supported" for this one, so the durable is
275
+ * left unfinished (as before) rather than sent a call the build refuses.
276
+ */
277
+ const finishWithoutTokenSupported = probeNativeSupport(() => {
278
+ const check = sdk.getFinishSupport;
279
+ return typeof check === 'function' ? check.call(sdk) : undefined;
280
+ }, (answer) => isRecord(answer)
281
+ && typeof answer.withoutAppAccountToken === 'number'
282
+ && answer.withoutAppAccountToken >= 1);
269
283
  // Overlapping finishes for the same transaction and ledger entry share one
270
284
  // native call; the v2 reconciler is the gate that authorizes the call.
271
285
  const inFlightFinishes = new Map();
272
286
  /**
273
287
  * Mirrors the native finish gate: the transaction must be listed, not
274
- * revoked, and bound to the session app account token. A missing list is
275
- * an invalid response, not a quiet "not finished".
288
+ * revoked, and bound to the session app account token (exceptions for a
289
+ * durable below). A missing list is an invalid response, not a quiet "not
290
+ * finished".
276
291
  *
277
292
  * On a retried finish (`retry`: the caller asked to finish this purchase
278
293
  * before, in an earlier attempt or session), a consumable the list no
@@ -297,8 +312,8 @@ export function createCapacitorStoreKitStoreSdk(input) {
297
312
  * reconciler cannot hold a grant for it from this SDK, and the answer
298
313
  * neither grants nor takes anything.
299
314
  *
300
- * Two exceptions for a listed, non-revoked durable, the only kind the
301
- * restore read admits from another token:
315
+ * Three exceptions for a listed, non-revoked durable, the only kind the
316
+ * restore read admits from another token or from none:
302
317
  *
303
318
  * - StoreKit already finished it (`unfinished: false`): nothing is left to
304
319
  * finish whatever its token, so a restore shared to this player ends
@@ -312,16 +327,24 @@ export function createCapacitorStoreKitStoreSdk(input) {
312
327
  * finishing it takes nothing from any player: the earlier guest's next
313
328
  * read still lists it (`unfinished: false`) for its own verification. It
314
329
  * is finished with its own token, which the native gate matches.
330
+ * - It is unfinished (or the build omits the flag), the catalog declares
331
+ * its SKU durable, and it is bound to no token (bought or redeemed
332
+ * outside the app, such as an offer code, then restored): the same
333
+ * reasoning holds, since the caller holds this player's ledger grant and
334
+ * StoreKit keeps the finished durable as a current entitlement. It is
335
+ * finished with the explicit no-token match (`withoutAppAccountToken`),
336
+ * which matches only a non-consumable bound to no token, and only when
337
+ * the native build declares it (`getFinishSupport`); an earlier build
338
+ * leaves it unfinished, and the caller retries as before.
339
+ *
340
+ * A consumable of another token or of no token, or a durable whose SKU the
341
+ * catalog does not declare durable, is never finished here.
315
342
  *
316
- * A consumable of another token, or a transaction bound to no token, is
317
- * never finished here.
343
+ * A revoked finish (`revoked`, see `revokedFinishPermitted`) has its own
344
+ * gate; an ordinary finish of a revoked transaction stays refused.
318
345
  */
319
346
  const finishPermitted = async (transactionId, appAccountToken, lostAcknowledgement) => {
320
- const response = await sdk.getTransactions();
321
- if (!isRecord(response) || !Array.isArray(response.transactions)) {
322
- throw evidenceInvalid('StoreKit returned an invalid transaction list.');
323
- }
324
- const entries = response.transactions.filter((candidate) => isRecord(candidate) && candidate.transactionId === transactionId);
347
+ const entries = await listedEntries(transactionId);
325
348
  const transaction = entries.find(isValidStoreKitTransaction);
326
349
  if (transaction === undefined) {
327
350
  return { verdict: entries.length === 0 && lostAcknowledgement ? 'finished' : 'refused' };
@@ -334,17 +357,61 @@ export function createCapacitorStoreKitStoreSdk(input) {
334
357
  }
335
358
  const bound = transaction.appAccountToken?.toLowerCase();
336
359
  if (bound === appAccountToken) {
337
- return { verdict: 'finish', appAccountToken };
360
+ return { verdict: 'finish', binding: { appAccountToken } };
361
+ }
362
+ if (transaction.type !== 'non_consumable' || kinds.get(transaction.productId) !== 'durable') {
363
+ return { verdict: 'refused' };
364
+ }
365
+ if (bound !== undefined) {
366
+ return { verdict: 'finish', binding: { appAccountToken: bound } };
367
+ }
368
+ return await finishWithoutTokenSupported()
369
+ ? { verdict: 'finish', binding: { withoutAppAccountToken: true } }
370
+ : { verdict: 'refused' };
371
+ };
372
+ /** The native list's entries of one transaction id, in any shape. */
373
+ const listedEntries = async (transactionId) => {
374
+ const response = await sdk.getTransactions();
375
+ if (!isRecord(response) || !Array.isArray(response.transactions)) {
376
+ throw evidenceInvalid('StoreKit returned an invalid transaction list.');
338
377
  }
339
- if (bound !== undefined
340
- && transaction.type === 'non_consumable'
341
- && kinds.get(transaction.productId) === 'durable') {
342
- return { verdict: 'finish', appAccountToken: bound };
378
+ return response.transactions.filter((candidate) => isRecord(candidate) && candidate.transactionId === transactionId);
379
+ };
380
+ /**
381
+ * The gate of a revoked finish (`revoked`): a consumable the App Store
382
+ * refunded while it was still unfinished, which StoreKit keeps in
383
+ * `Transaction.unfinished` and redelivers at every launch until `finish()`.
384
+ * The caller (the reconciler) asks only once the backend answered for it
385
+ * without a standing grant, and finishing it grants nothing: it only stops
386
+ * the redelivery. It is finished only when the native list carries it as
387
+ * revoked (`revokedAt`), as a consumable of a SKU the catalog declares
388
+ * consumable, bound to the session token. A transaction that is not
389
+ * revoked (still a paid purchase), a durable, or a consumable of another
390
+ * or no token stays refused, as does an entry of the id in a malformed
391
+ * shape. One the list does not carry is refused too (`finished: false`,
392
+ * no native call): the list cannot tell a consumable already finished
393
+ * (a refund of a finished consumable is reported as well) from one a read
394
+ * early in the launch does not carry yet, and answering finished would
395
+ * report a finish that never happened. The reconciler does not count that
396
+ * answer as asked, so a later report of the transaction asks again.
397
+ */
398
+ const revokedFinishPermitted = async (transactionId, appAccountToken) => {
399
+ const entries = await listedEntries(transactionId);
400
+ const transaction = entries.find(isValidStoreKitTransaction);
401
+ if (transaction === undefined) {
402
+ return 'refused';
343
403
  }
344
- return { verdict: 'refused' };
404
+ return transaction.revokedAt !== undefined
405
+ && transaction.type === 'consumable'
406
+ && kinds.get(transaction.productId) === 'consumable'
407
+ && transaction.appAccountToken?.toLowerCase() === appAccountToken
408
+ ? 'finish'
409
+ : 'refused';
345
410
  };
346
411
  return {
347
412
  store: appStoreStoreId,
413
+ // `finishTransaction` reads `revoked` (see `revokedFinishPermitted`).
414
+ acceptsRevokedFinish: true,
348
415
  ...(presentation === undefined ? {} : { purchasePresentation: presentation.purchasePresentation }),
349
416
  capabilities: storeKitStoreSdkCapabilities,
350
417
  async fetchProducts(request) {
@@ -463,38 +530,70 @@ export function createCapacitorStoreKitStoreSdk(input) {
463
530
  requireIos();
464
531
  // The session token is the binding. A bridge or plugin caller cannot
465
532
  // substitute another account's token on this path: the only other
466
- // token used is a durable's own, read from the native list.
533
+ // bindings used are a durable's own token, read from the native list,
534
+ // and the explicit no-token match for a durable the list shows unbound.
467
535
  const appAccountToken = await Promise.resolve(resolveAppAccountToken());
468
- const { transactionId, ledgerEntryId } = request;
536
+ const { transactionId } = request;
537
+ const revoked = request.revoked === true;
538
+ // A revoked finish may name no ledger entry (nothing was ever granted);
539
+ // a given one must still be non-empty, and an ordinary finish needs it.
540
+ const given = request.ledgerEntryId;
469
541
  if (typeof transactionId !== 'string' || !transactionPattern.test(transactionId)
470
- || typeof ledgerEntryId !== 'string' || ledgerEntryId.trim() === '') {
542
+ || (given === undefined
543
+ ? !revoked
544
+ : typeof given !== 'string' || given.trim() === '')) {
471
545
  throw storeKitError('STOREKIT_FINISH_REQUIRES_GRANT', 'StoreKit finish requires a transaction id and the ledger entry that granted it.', false);
472
546
  }
547
+ // Native detail: the plugin's `finishTransaction` requires a non-empty
548
+ // `ledgerEntryId` on every call. A revoked finish without a ledger entry
549
+ // sends `revokedLedgerEntryIdPrefix` + the transaction id there; it names
550
+ // no ledger entry and the native code never reads it.
551
+ const ledgerEntryId = typeof given === 'string'
552
+ ? given
553
+ : `${revokedLedgerEntryIdPrefix}${transactionId}`;
473
554
  const active = inFlightFinishes.get(transactionId);
474
- if (active !== undefined && active.ledgerEntryId === ledgerEntryId) {
555
+ if (active !== undefined
556
+ && active.ledgerEntryId === ledgerEntryId
557
+ && active.revoked === revoked) {
475
558
  return active.promise;
476
559
  }
477
560
  const promise = (async () => {
478
- // Same match the native plugin applies: missing, revoked, unbound, or
479
- // another account never reaches `transaction.finish()`, except a
480
- // durable of another token (see `finishPermitted`). On a retry, a
561
+ if (revoked) {
562
+ if (request.isConsumable !== true) {
563
+ return { finished: false };
564
+ }
565
+ if (await revokedFinishPermitted(transactionId, appAccountToken) !== 'finish') {
566
+ return { finished: false };
567
+ }
568
+ // Always the session token, never the no-token match. A native
569
+ // build from before the flag ignores it and applies its ordinary
570
+ // gate, which refuses a revoked transaction: `finished: false`.
571
+ return readFinishResult(await sdk.finishTransaction({
572
+ transactionId,
573
+ ledgerEntryId,
574
+ appAccountToken,
575
+ revoked: true,
576
+ }));
577
+ }
578
+ // Same match the native plugin applies: missing, revoked, or another
579
+ // account never reaches `transaction.finish()`, except a durable of
580
+ // another token or of none (see `finishPermitted`). On a retry, a
481
581
  // consumable that left the unfinished list is already finished (a
482
582
  // lost acknowledgement).
483
583
  const permitted = await finishPermitted(transactionId, appAccountToken, request.isConsumable === true && request.retry === true);
484
584
  if (permitted.verdict !== 'finish') {
485
585
  return { finished: permitted.verdict === 'finished' };
486
586
  }
487
- const reported = await sdk.finishTransaction({
488
- transactionId,
489
- ledgerEntryId,
490
- appAccountToken: permitted.appAccountToken,
491
- });
492
- if (!isRecord(reported) || typeof reported.finished !== 'boolean') {
493
- throw evidenceInvalid('StoreKit returned an invalid finish result.');
494
- }
495
- return { finished: reported.finished };
587
+ const { binding } = permitted;
588
+ return readFinishResult('withoutAppAccountToken' in binding
589
+ ? await sdk.finishTransaction({ transactionId, ledgerEntryId, withoutAppAccountToken: true })
590
+ : await sdk.finishTransaction({
591
+ transactionId,
592
+ ledgerEntryId,
593
+ appAccountToken: binding.appAccountToken,
594
+ }));
496
595
  })();
497
- inFlightFinishes.set(transactionId, { ledgerEntryId, promise });
596
+ inFlightFinishes.set(transactionId, { ledgerEntryId, revoked, promise });
498
597
  try {
499
598
  return await promise;
500
599
  }
@@ -597,6 +696,12 @@ export const capacitorStoreKitErrorCodeMap = Object.freeze({
597
696
  ...storeKitStoreSdkErrorCodeMap,
598
697
  });
599
698
  const maxProductIdsPerLookup = 100;
699
+ /**
700
+ * The `ledgerEntryId` the SDK sends the native plugin for a revoked finish
701
+ * that names no ledger entry (a refund of a purchase never granted). The
702
+ * native method requires the field on every call; it is not a ledger entry.
703
+ */
704
+ export const revokedLedgerEntryIdPrefix = 'revoked-without-grant:';
600
705
  const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
601
706
  const nilUuid = '00000000-0000-0000-0000-000000000000';
602
707
  const productPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
@@ -676,6 +781,12 @@ function isValidStoreKitProduct(value) {
676
781
  && typeof value.formattedPrice === 'string'
677
782
  && typeof value.currencyCode === 'string' && value.currencyCode.length > 0;
678
783
  }
784
+ function readFinishResult(reported) {
785
+ if (!isRecord(reported) || typeof reported.finished !== 'boolean') {
786
+ throw evidenceInvalid('StoreKit returned an invalid finish result.');
787
+ }
788
+ return { finished: reported.finished };
789
+ }
679
790
  function storeKitError(code, message, retryable) {
680
791
  return Object.assign(new Error(message), { code, retryable });
681
792
  }
@@ -19,7 +19,8 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
19
19
  CAPPluginMethod(name: "getTransactions", returnType: CAPPluginReturnPromise),
20
20
  CAPPluginMethod(name: "sync", returnType: CAPPluginReturnPromise),
21
21
  CAPPluginMethod(name: "finishTransaction", returnType: CAPPluginReturnPromise),
22
- CAPPluginMethod(name: "getPresentationSupport", returnType: CAPPluginReturnPromise)
22
+ CAPPluginMethod(name: "getPresentationSupport", returnType: CAPPluginReturnPromise),
23
+ CAPPluginMethod(name: "getFinishSupport", returnType: CAPPluginReturnPromise)
23
24
  ]
24
25
 
25
26
  private let connection = OpenIapAppleConnection()
@@ -89,6 +90,15 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
89
90
  call.resolve(["purchasePresentation": MpgdPurchaseSheet.protocolVersion])
90
91
  }
91
92
 
93
+ /// Declares the finish matches this native code applies beyond the
94
+ /// session token: `withoutAppAccountToken`, the explicit no-token match
95
+ /// for a non-consumable bound to no token. A build without this method
96
+ /// answers `UNIMPLEMENTED`, and the TypeScript SDK then leaves such a
97
+ /// transaction unfinished, as before.
98
+ @objc func getFinishSupport(_ call: CAPPluginCall) {
99
+ call.resolve(["withoutAppAccountToken": StoreKitTransactionPolicy.finishWithoutAppAccountTokenVersion])
100
+ }
101
+
92
102
  @objc func purchase(_ call: CAPPluginCall) {
93
103
  // Correlates this call's sheet facts; sent only by a TypeScript SDK
94
104
  // that reads them. Without it the call reports no facts, as before.
@@ -241,11 +251,15 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
241
251
  // SDK supplies the session token and applies the same match before
242
252
  // calling. A signed server finish capability is still required to stop
243
253
  // a webview caller who read the token from `getTransactions`.
254
+ // `withoutAppAccountToken: true` (and no token argument) instead
255
+ // matches only a non-consumable bound to no token; a missing token
256
+ // without it is refused, as before.
244
257
  guard let transactionId = call.getString("transactionId"), UInt64(transactionId) != nil,
245
258
  let ledgerEntryId = call.getString("ledgerEntryId"),
246
259
  !ledgerEntryId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
247
- let accountToken = StoreKitTransactionPolicy.normalizedAccountToken(
248
- call.getString("appAccountToken")) else {
260
+ let binding = StoreKitTransactionPolicy.finishBinding(
261
+ appAccountToken: call.options["appAccountToken"],
262
+ withoutAppAccountToken: call.options["withoutAppAccountToken"]) else {
249
263
  call.reject(
250
264
  "A transaction ID, confirmed ledger entry, and app account token are required.",
251
265
  "STOREKIT_FINISH_REQUIRES_GRANT"
@@ -253,28 +267,52 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
253
267
  return
254
268
  }
255
269
  guard providerReady(call) else { return }
270
+ // `revoked`: finish a consumable the App Store refunded while it was
271
+ // unfinished, so StoreKit stops redelivering it. Grants nothing; the
272
+ // StoreKit SDK asks only once the game backend answered for it
273
+ // without a standing grant. Only a verified, revoked, unfinished
274
+ // consumable bound to the session token matches
275
+ // (`StoreKitTransactionPolicy.finishableRevoked`), read from
276
+ // `Transaction.unfinished` only; anything else answers
277
+ // `finished: false` and is left alone.
278
+ let revoked = call.getBool("revoked") == true
256
279
  Task { [module] in
257
280
  do {
258
- // The verified transaction behind the id, through the same read
259
- // as `getTransactions` (an unverified sibling never blocks it):
260
- // unfinished first, then the StoreKit history
261
- // (`Transaction.all`) the 0.3 plugin searched, so finishing an
262
- // already-finished durable stays an idempotent `finished: true`.
263
- var candidate = StoreKitTransactionPolicy.finishable(
264
- transactionId: transactionId,
265
- appAccountToken: accountToken,
266
- in: await RecoveryReadShim.unfinished())
267
- if candidate == nil {
281
+ var candidate: MpgdStoreKitTransaction?
282
+ if revoked {
283
+ // Only the session token, never the no-token match: a
284
+ // revoked finish of a call without a token finds nothing.
285
+ if case .appAccountToken(let token) = binding {
286
+ candidate = StoreKitTransactionPolicy.finishableRevoked(
287
+ transactionId: transactionId,
288
+ appAccountToken: token,
289
+ in: await RecoveryReadShim.unfinished())
290
+ }
291
+ } else {
292
+ // The verified transaction behind the id, through the same
293
+ // read as `getTransactions` (an unverified sibling never
294
+ // blocks it): unfinished first, then the StoreKit history
295
+ // (`Transaction.all`) the 0.3 plugin searched, so finishing
296
+ // an already-finished durable stays an idempotent
297
+ // `finished: true`.
268
298
  candidate = StoreKitTransactionPolicy.finishable(
269
299
  transactionId: transactionId,
270
- appAccountToken: accountToken,
271
- in: await RecoveryReadShim.history())
300
+ binding: binding,
301
+ in: await RecoveryReadShim.unfinished())
302
+ if candidate == nil {
303
+ candidate = StoreKitTransactionPolicy.finishable(
304
+ transactionId: transactionId,
305
+ binding: binding,
306
+ in: await RecoveryReadShim.history())
307
+ }
272
308
  }
273
309
  guard let match = candidate else {
274
310
  call.resolve(["finished": false])
275
311
  return
276
312
  }
277
- // OpenIAP finishes it (and checks the Apple storeId).
313
+ // OpenIAP finishes it by id from `Transaction.unfinished` (and
314
+ // checks the Apple storeId); it does not check the revocation
315
+ // date.
278
316
  try await module.finishTransaction(
279
317
  purchase: .purchaseIos(StoreKitTransactionPolicy.finishInput(match)),
280
318
  isConsumable: match.type == .consumable)
@@ -219,6 +219,95 @@ public enum StoreKitTransactionPolicy {
219
219
  return nil
220
220
  }
221
221
 
222
+ /// The transaction a revoked finish (`finishTransaction` with `revoked`)
223
+ /// may finish: the verified, **revoked** consumable with this id whose app
224
+ /// account token equals the session token. The App Store refunded it while
225
+ /// it was unfinished, so StoreKit keeps it in `Transaction.unfinished` and
226
+ /// redelivers it at every launch until `finish()`; finishing it grants
227
+ /// nothing. A transaction that is not revoked (still paid), a durable, and
228
+ /// another or no token never match, so this path never finishes a
229
+ /// purchase the player still holds. Read from `Transaction.unfinished`
230
+ /// only: a finished one has nothing left to finish.
231
+ public static func finishableRevoked(
232
+ transactionId: String,
233
+ appAccountToken: String,
234
+ in entries: [ReadEntry]
235
+ ) -> MpgdStoreKitTransaction? {
236
+ let expected = appAccountToken.lowercased()
237
+ for entry in entries {
238
+ guard case .oneTime(let transaction) = entry,
239
+ transaction.transactionId == transactionId,
240
+ transaction.revokedAtMillis != nil,
241
+ transaction.type == .consumable,
242
+ transaction.appAccountToken == expected else { continue }
243
+ return transaction
244
+ }
245
+ return nil
246
+ }
247
+
248
+ /// The protocol version of the explicit no-token finish
249
+ /// (`finishTransaction` with `withoutAppAccountToken: true`), declared by
250
+ /// `getFinishSupport`. A build from before it answers `UNIMPLEMENTED`
251
+ /// there and refuses such a call, so the TypeScript SDK never sends one.
252
+ public static let finishWithoutAppAccountTokenVersion = 1
253
+
254
+ /// Which app account token one `finishTransaction` call matches.
255
+ public enum FinishBinding: Equatable, Sendable {
256
+ /// The caller's (lowercased, non-nil UUID) token; see `finishable`.
257
+ case appAccountToken(String)
258
+ /// Explicitly none: matches only a non-consumable bound to no token.
259
+ case withoutAppAccountToken
260
+ }
261
+
262
+ /// The binding a `finishTransaction` call asks for, from its raw
263
+ /// arguments; nil refuses the call. Without `withoutAppAccountToken` it is
264
+ /// the token path of earlier builds (a valid, non-nil UUID). With it, only
265
+ /// the literal `true` and no `appAccountToken` argument at all (not even
266
+ /// null) select the no-token match. A missing token is never read as "no
267
+ /// token", and never as a wildcard.
268
+ public static func finishBinding(appAccountToken: Any?, withoutAppAccountToken: Any?) -> FinishBinding? {
269
+ guard let flag = withoutAppAccountToken else {
270
+ guard let token = normalizedAccountToken(appAccountToken as? String) else { return nil }
271
+ return .appAccountToken(token)
272
+ }
273
+ guard isBooleanTrue(flag), appAccountToken == nil else { return nil }
274
+ return .withoutAppAccountToken
275
+ }
276
+
277
+ /// A JSON `true` as Capacitor hands it over (a CFBoolean). A number such
278
+ /// as `1` is an `NSNumber` that `as? Bool` would also read as true.
279
+ static func isBooleanTrue(_ value: Any) -> Bool {
280
+ guard let number = value as? NSNumber else { return false }
281
+ return CFGetTypeID(number) == CFBooleanGetTypeID() && number.boolValue
282
+ }
283
+
284
+ /// `finishable` for either binding. A token matches only a transaction
285
+ /// bound to that token (`finishable(transactionId:appAccountToken:in:)`).
286
+ /// `withoutAppAccountToken` matches only a verified, non-revoked
287
+ /// non-consumable bound to no token (one bought or redeemed outside the
288
+ /// app); never a consumable and never a transaction that carries a token.
289
+ /// The TypeScript SDK sends it only for a durable of its catalog after the
290
+ /// backend's ledger grant; StoreKit keeps a finished non-consumable in
291
+ /// `Transaction.currentEntitlements`, so the finish takes nothing away.
292
+ public static func finishable(
293
+ transactionId: String,
294
+ binding: FinishBinding,
295
+ in entries: [ReadEntry]
296
+ ) -> MpgdStoreKitTransaction? {
297
+ if case .appAccountToken(let token) = binding {
298
+ return finishable(transactionId: transactionId, appAccountToken: token, in: entries)
299
+ }
300
+ for entry in entries {
301
+ guard case .oneTime(let transaction) = entry,
302
+ transaction.transactionId == transactionId,
303
+ transaction.revokedAtMillis == nil,
304
+ transaction.type == .nonConsumable,
305
+ transaction.appAccountToken == nil else { continue }
306
+ return transaction
307
+ }
308
+ return nil
309
+ }
310
+
222
311
  /// The OpenIAP purchase handed to `OpenIapModule.finishTransaction` for a
223
312
  /// transaction the read resolved. OpenIAP finishes by transaction id
224
313
  /// (looking it up in `Transaction.unfinished`, then
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/capacitor-storekit",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Opt-in StoreKit 2 one-time purchase collector and commerce v2 provider for mpgd Capacitor games.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,18 +38,18 @@
38
38
  ],
39
39
  "peerDependencies": {
40
40
  "@capacitor/core": "^8.5.1",
41
- "@mpgd/adapter-capacitor": "^0.18.0",
41
+ "@mpgd/adapter-capacitor": "^0.19.0",
42
42
  "@mpgd/bridge": "^0.18.0",
43
- "@mpgd/platform": "^0.34.0"
43
+ "@mpgd/platform": "^0.35.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@capacitor/core": "8.5.2",
47
47
  "ttsc": "0.34.2",
48
48
  "typescript": "7.0.2",
49
- "@mpgd/adapter-capacitor": "0.18.0",
50
- "@mpgd/bridge": "0.18.0",
51
- "@mpgd/platform": "0.34.0",
52
- "@mpgd/game-services": "0.32.0"
49
+ "@mpgd/adapter-capacitor": "0.19.1",
50
+ "@mpgd/bridge": "0.18.1",
51
+ "@mpgd/game-services": "0.33.1",
52
+ "@mpgd/platform": "0.35.0"
53
53
  },
54
54
  "main": "./dist/index.js",
55
55
  "types": "./dist/index.d.ts",