@nosslabs/iap 8.0.0 → 8.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,7 +3,539 @@
3
3
  All notable changes to `@nosslabs/iap` will be documented here.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [Semantic Versioning](https://semver.org/).
5
5
 
6
- ## [Unreleased]
6
+ ## [8.2.0] — 2026-10-27
7
+
8
+ ### Changed
9
+
10
+ - **`@capacitor/preferences` is now an optional peer, loaded only when it is used.** It was a
11
+ required peer reached through a *static* import, so it ended up in the bundle's top-level
12
+ imports and every consumer shipped the native plugin — including one passing
13
+ `storage.type: 'custom'` with its own storage, which never calls Preferences at all.
14
+
15
+ `PreferencesAdapter` now reaches the plugin through `await import()`, memoised per instance,
16
+ the way the resume listener reaches `@capacitor/app`. Neither `dist/index.js` nor
17
+ `dist/index.cjs` names it at the top level any more, asserted on the built artifact by a new
18
+ `npm run verify:bundle` step in CI and before publish — a static import behaves identically
19
+ at runtime and differs only in what the bundler hoists, so no unit test can see it.
20
+
21
+ Choosing `storage.type: 'preferences'` (still the default) without the plugin installed now
22
+ fails at `initialize()` with `INVALID_CONFIG`, naming what to install and the two
23
+ alternatives that need no plugin. It is reported there, via the new optional
24
+ `StorageAdapter.ensureAvailable()`, rather than at the first cache read: that read treats any
25
+ failure as "nothing cached", so a missing plugin would have looked like a cold start while
26
+ iap silently persisted nothing.
27
+
28
+ **If you use `storage.type: 'preferences'`, keep the plugin installed** — but check it is in
29
+ your *own* `dependencies`. npm 7+ installs a required peer into the tree without listing it
30
+ in your `package.json`; now that it is optional, a fresh install or a regenerated lockfile
31
+ can drop it, and `npx cap sync` with it.
32
+
33
+ If you pass your own adapter or use `'memory'`, you can uninstall it — and then you must
34
+ **externalise it in your bundler**, because a dynamic `import()` is still resolved at build
35
+ time: without it Vite/Rollup fails with `failed to resolve import "@capacitor/preferences"`.
36
+ The installation guide has the one-line config. (This is not new to this release — the
37
+ optional `@capacitor/app` has always behaved the same way — but it is now worth stating.)
38
+
39
+ - **Developed and tested against `@capgo/native-purchases@8.9.6`** (was `8.6.5`). The peer
40
+ range is unchanged at `^8.2.0`. Nothing in iap broke, and two of the plugin's documented
41
+ gaps closed — both caught by the inverted contract guards rather than by reading release
42
+ notes:
43
+
44
+ - **Purchase tokens no longer reach logcat.** Through `8.6.5` the Android plugin logged them
45
+ in the clear with no debug gate, which [the native-layer
46
+ page](https://iap.nossdev.com/guide/native-layer) warned about. Every Android log now goes
47
+ through a `NativePurchasesLog` wrapper gated on `BuildConfig.DEBUG` or an explicit
48
+ `debugLogging` config, and the purchase summary it emits carries no token. Verified by
49
+ asserting that no `android.util.Log` call survives outside the wrapper, so a regression
50
+ fails CI; the warning is off the page.
51
+ - **`purchaseProduct()` accepts an `offerToken`.** Choosing a specific offer within a base
52
+ plan is now possible at the native layer. iap does not forward one yet — `purchase()` has
53
+ no way to name an offer — so this is recorded as a feature to build rather than a gap to
54
+ live with.
55
+
56
+ The plugin also moved to **Play Billing Library 9.1.0** (from 8.3.0), a native major. It
57
+ changes nothing iap relies on, and that was checked against the 9.1.0 artifact rather than
58
+ assumed: `Purchase.PurchaseState` still reads `UNSPECIFIED_STATE = 0, PURCHASED = 1,
59
+ PENDING = 2`, and `consumeAsync`, `acknowledgePurchase` and `queryPurchasesAsync` keep their
60
+ signatures — the three facts the Android purchase-state check and the consumable path are
61
+ built on.
62
+
63
+ ## [8.1.0] — 2026-10-27
64
+
65
+ ### Changed
66
+
67
+ - **`peerDependencies` now require `@capgo/native-purchases` `^8.2.0`**, up from `^8.0.0`.
68
+ `consumePurchase()` landed in 8.2.0, and the consumable path above needs it: on 8.0.x/8.1.x
69
+ the call would reject at the bridge and be retried forever rather than degrading.
70
+
71
+ ### Added
72
+
73
+ - **Local verification — iap works with no backend at all.** `verification: { mode: 'local',
74
+ entitlements }` replaces the `backend` block and verifies purchases on the device. Exactly
75
+ one of the two is required; both is a config error.
76
+
77
+ Implemented as a `BackendAdapter`, which is the whole design: everything above that boundary
78
+ is **untouched**. The purchase flow, the acknowledge-only-after-verification ordering, the
79
+ at-least-once write before verifying, and the unfinished-transaction recovery all behave
80
+ identically and do not know which mode they are in. There is no second purchase path to keep
81
+ in step, and a purchase whose local check fails is never acknowledged — so the store keeps it
82
+ and recovery retries, which on Android is what prevents the 3-day auto-refund.
83
+
84
+ `refresh()` and `restorePurchases()` both ask the store what it owns, so they keep working
85
+ with no network.
86
+
87
+ **Be clear about the strength.** Every verified purchase carries an `assurance`:
88
+ `store-signed` on iOS, where StoreKit verified Apple's signature in native code before the
89
+ transaction reached the WebView, and `store-reported` on Android, where **no cryptographic
90
+ check is possible** — the native layer exposes neither Play's purchase signature nor the JSON
91
+ it is computed over, so the answer is the store's word. Neither resists a lying device.
92
+ Local mode is for an app whose alternative is no verification at all; it is not a substitute
93
+ for a server where being wrong is expensive.
94
+
95
+ The checks are exported as `verifyPurchaseOnDevice` for consumers who would rather write
96
+ their own adapter around them.
97
+
98
+ - **Offline semantics, written down as guarantees** — [Offline and no-backend
99
+ use](https://iap.nossdev.com/guide/offline-and-no-backend). Most of this was already the
100
+ behaviour; it was never stated, so it could not be relied on.
101
+
102
+ - **`options.entitlementGraceMs`** — extra time past `expiresAt` during which an entitlement
103
+ still counts. Defaults to `0`. Set it because a renewal is not instantaneous: a subscription
104
+ renewing at midnight may not have a fresh receipt for hours, and locking a paying user out in
105
+ that window is the worse error. On Android store-side grace and billing retry are
106
+ server-side only, so a backendless app cannot see them and this is its only cushion.
107
+
108
+ - **`iap.getEntitlementsVerifiedAt()`** — when the entitlements were last confirmed, ISO 8601
109
+ or `null`. iap deliberately does not revoke on staleness, so this is for an app that wants to
110
+ be stricter than iap is.
111
+
112
+ - **`iap.reset()`** — erase the entitlement cache, the unfinished-transaction queue and the
113
+ pending-purchase marks. For a "delete everything" control. Not `destroy()`, which keeps the
114
+ cache so the next instance reads it immediately; and not a revocation — the store still owns
115
+ the purchase, and a restore brings the entitlement back, which is correct because the user
116
+ paid.
117
+
118
+ Every key comes from one registry, so a store added in a later version cannot quietly survive
119
+ a wipe, and each is removed **by name** as well as through its store — the Preferences adapter
120
+ can only clear keys it has seen this session, so clearing the namespace alone is a no-op on a
121
+ fresh launch.
122
+
123
+ It throws `ALREADY_IN_PROGRESS` (as `recoverable`) while a `purchase()` is in flight: that
124
+ purchase's unfinished-transaction record is written before verification precisely so it
125
+ survives a crash, and deleting it mid-flight would leave a purchase neither acknowledged nor
126
+ tracked — which Google auto-refunds after three days. Await the purchase, then reset. After
127
+ `destroy()` it still wipes and does not throw; that is the safest moment, since no
128
+ orchestrator is left to re-persist.
129
+
130
+ - **`verification.appUserId` accepts a getter**, `string | (() => string | null)`, read at the
131
+ time of each check so the binding follows the session. A fixed string is captured at
132
+ `createIAP()` time, so in a multi-user app it is the previous user's after a login change and
133
+ every purchase made afterwards is refused as a mismatch, with no recovery short of rebuilding
134
+ the instance.
135
+
136
+ A getter returning `null` means "nobody is signed in", which is **not** the same as using no
137
+ binding: purchases and restores are refused (`BINDING_UNAVAILABLE`) rather than falling back
138
+ to an unguarded check, since the alternative is accepting a purchase bound to someone else. A
139
+ refresh in that state keeps the current entitlements instead of revoking them — a multi-user
140
+ app restores its session asynchronously, and "I cannot tell you" must never read as "you own
141
+ nothing".
142
+
143
+ - **Android consumables are consumed, not just acknowledged, so they can be bought again.**
144
+ Play treats acknowledging and consuming as different things, and only consuming releases
145
+ ownership. A consumable that cleared out of band — a restore, or launch recovery — was
146
+ acknowledged and then stayed owned forever, so `purchase()` of it failed `ALREADY_PURCHASED`
147
+ permanently. With a backend the entitlement was unaffected and only the product was
148
+ unbuyable; in local mode a mapper that granted something for a consumable kept granting it
149
+ on every refresh, since the purchase never left the store's owned set.
150
+
151
+ Three parts. `NativeAdapter` gains an optional **`consume(transaction)`** (capgo's
152
+ `consumePurchase()`); a custom adapter without it falls back to acknowledging and logs that
153
+ the product will stay owned. All four settle paths now go through one `finalizeTransaction`
154
+ step that decides acknowledge-vs-consume. And a store-reported transaction is typed from
155
+ **your configured catalog**, because Play reports a consumable and a one-time purchase both
156
+ as `'inapp'` — the plugin's payload cannot tell them apart. The catalog only *refines* the
157
+ store's answer: it can turn `'inapp'` into `'consumable'`, and never contradicts a `'subs'`,
158
+ so one wrong `type` in a config or a stale `backend.listProducts()` manifest cannot strip a
159
+ subscription's expiry and have it granted forever.
160
+
161
+ iap also no longer passes the plugin's `isConsumable` flag. The plugin consumes on that flag
162
+ inside the purchase callback **regardless of `autoAcknowledgePurchases`**, which contradicts
163
+ that option's own documentation and would have finalised a purchase before iap verified it —
164
+ unrecoverably, since Billing 8.x drops a consumed purchase from `getPurchases()`. Found by
165
+ reading the plugin's Java rather than its docs, and now guarded by a test so that if capgo
166
+ closes the gap iap can stop working around it.
167
+
168
+ Two things to know if you sell consumables. A consumed purchase leaves `getPurchases()` for
169
+ good in Billing 8, so **in local mode an entitlement derived from a consumable now
170
+ disappears at the next refresh** — which is what `LocallyVerifiedPurchase.productType` has
171
+ always warned about, but it used to be masked. Keep a consumable's effect in your own
172
+ records, not in a standing entitlement. And `restorePurchases()` now clears a pending mark
173
+ it settles, which it never did: harmless while a purchase stayed owned for the next
174
+ reconcile to find, but a consumed one is gone, and the reconcile would otherwise have
175
+ reported a succeeded purchase as `cancelled`.
176
+
177
+ - **`LocalVerificationError` gains `BINDING_UNAVAILABLE`.** Additive, but it widens a public
178
+ union, so an exhaustive `switch` over it will need the new arm.
179
+
180
+ - **`BackendAdapter.verifyNative`**, optional. An adapter that can answer from the whole
181
+ transaction gets it whole, rather than the extracted `{ productId, token, type }` the HTTP
182
+ contract sends — an on-device check needs the binding, the subscription state and the expiry,
183
+ none of which survive that extraction. The HTTP adapter does not implement it, so nothing
184
+ about what goes over the wire changes.
185
+
186
+ ### Changed
187
+
188
+ - **In local mode, `hasEntitlement()`, `getEntitlement()` and `getEntitlements()` return only
189
+ *active* entitlements** — ones whose `expiresAt` has not passed by the device clock, plus any
190
+ `entitlementGraceMs`. With no backend to re-ask, the deadline is the only thing that can
191
+ revoke anything, so enforcing it locally is the point.
192
+
193
+ **With a backend they do not filter**, deliberately: the backend is the authority on what is
194
+ granted, and it may return an entitlement whose `expiresAt` has passed on purpose — that is
195
+ what a store-side grace period or billing retry looks like. Second-guessing it against the
196
+ device clock would have iap perform a downgrade the backend did not ask for, and a device
197
+ with a fast clock would lock out a paying user even with the backend reachable. So this is
198
+ **not** a behaviour change for existing backend-mode consumers.
199
+
200
+ In neither mode is an entitlement dropped because a refresh failed or the cache is old.
201
+
202
+ - **`backend` is now optional in `IAPConfig`**, since `verification` can take its place. Code
203
+ that reads `config.backend` needs a null check; code that passes one is unaffected.
204
+
205
+ - **Introductory and promotional offers on `getProducts()`.** Each subscription now carries
206
+ `introductoryOffer` and `promotionalOffers` as typed `SubscriptionOffer`s — offer type,
207
+ payment mode (`free-trial` / `pay-up-front` / `pay-as-you-go`), the store's own localized
208
+ price string plus micros, the period as an ISO 8601 duration, and the cycle count. Build
209
+ paywall copy from these rather than hardcoding a price. `subscriptionPeriod`,
210
+ `subscriptionGroupId`, `androidOfferToken` and `androidOfferId` come along too.
211
+
212
+ **Eligibility is not among them, and cannot be.** The native layer exposes no eligibility
213
+ query on either platform, so an offer being present means "this product has one", not
214
+ "this user will get it" — the store's purchase sheet decides at purchase time. Write copy
215
+ that reads correctly either way, or confirm server-side.
216
+
217
+ - **Transactions that happen outside a purchase now reach the consumer.** iap subscribes to
218
+ Apple's `Transaction.updates` from `initialize()`, so a renewal, a refund or revocation, an
219
+ Ask-to-Buy approval, or a pending purchase clearing is verified, acknowledged and reflected
220
+ in entitlements while the app is running — previously none of that surfaced until the next
221
+ launch. Two new events: `transaction-updated` (the store says something happened, before
222
+ verification) and the authoritative `entitlements-changed` that follows.
223
+
224
+ **iOS only, because that is the only feed that exists**: the native layer has no Android
225
+ equivalent. On Android the same ground is covered by reconciliation at launch and on resume,
226
+ which is what the previous behaviour already relied on for both platforms.
227
+
228
+ An arriving transaction goes through the same path recovery uses — persisted before
229
+ verification, acknowledged only after the backend confirms — rather than a second settlement
230
+ path that could drift from it. One that arrives while `purchase()` is handling the same
231
+ product is left to the purchase flow, since StoreKit delivers it to both and settling twice
232
+ would verify twice, acknowledge twice and emit a spurious change.
233
+
234
+ - **`purchase-pending-resolved` — pending purchases now close out.** `purchase-pending` had no
235
+ counterpart, so a paywall that put up "waiting for payment" had nothing to take it back down
236
+ with. The new event reports `purchased` (cleared, and the entitlement is already in place) or
237
+ `cancelled`, at most once per pending purchase.
238
+
239
+ Pending purchases are remembered across launches, because that is when they resolve — a Play
240
+ e-wallet payment can take hours with the app closed. The mark is checked at `initialize()`
241
+ and on every resume, so the event can arrive in a much later session than the purchase.
242
+
243
+ **`cancelled` is Android-only, deliberately.** It fires only where the platform can tell
244
+ "gone" from "still waiting" — Play's purchase state. On iOS a deferred purchase that is never
245
+ approved produces no signal at all, so iap says nothing rather than guessing and clearing a
246
+ paywall's pending copy while a parent is still deciding. A mark iap cannot resolve is dropped
247
+ after 30 days, silently and with no event: by then it is iap's unanswered question rather
248
+ than a purchase in progress, and keeping it would make every foreground re-query the store
249
+ for the life of the install.
250
+
251
+ Known limitation, documented rather than half-fixed: a **consumable** that clears out of band
252
+ is acknowledged but not *consumed*, so Play keeps reporting it owned and it cannot be
253
+ rebought. Entitlement is unaffected. Closing it needs a consume path in the native adapter.
254
+
255
+ - **`appUserId` accepts any RFC 9562 UUID, not only v4.** A deterministic identifier is often
256
+ the better choice: a UUIDv5 over a stable name gives the same binding on every device with
257
+ nothing to persist or keep in step, and v7 is the current recommendation for time-ordered
258
+ ids. Restricting to v4 forced consumers who already had a deterministic identity to mint a
259
+ second one.
260
+
261
+ Still refused: anything that is not a canonical UUID, non-RFC variant nibbles (which Apple
262
+ rejects anyway), and the nil and max UUIDs — both are valid RFC 9562 constructs that
263
+ deliberately carry no identity, so accepting one would bind a purchase to nothing while
264
+ looking well-formed.
265
+
266
+ - **`StorePurchase`, on `purchase()` and `restorePurchases()` — the store's own view of a
267
+ purchase.** `result.purchase` on a successful purchase, and `result.purchases` on a restore.
268
+ It carries `purchaseReference`, `appUserId` and `subscription`, which is what makes the
269
+ three fields below reachable: `purchase()` hands back a `VerifiedTransaction` (your
270
+ backend's view of the receipt) and `NativeTransaction` is internal, so without this the
271
+ normalized fields would have been computed and never readable by anyone.
272
+
273
+ `restorePurchases()` is the route that matters for a reference you need *later*: after a
274
+ reinstall or on a new device, the install that made the purchase is gone and cannot have
275
+ kept it.
276
+
277
+ - **`NativeTransaction.purchaseReference` — the reference that survives renewals.** `token`
278
+ is the handle for acknowledging *this* transaction, and on Apple it is a different value
279
+ after every renewal; `purchaseReference` is the identity of the purchase itself, so it is
280
+ what to store to recognise a subscription later. Google's `purchaseToken` already is one.
281
+ Apple's `originalTransactionId` is not exposed as a plugin field, so iap reads it from the
282
+ signed `jwsRepresentation` payload — **for its data, with no signature check**, which is
283
+ sound only because the native layer resolves a purchase solely for an already-verified
284
+ StoreKit 2 result. It falls back to `token` when the JWS is absent or unreadable, so it is
285
+ always set, and a malformed JWS can never fail a purchase.
286
+
287
+ It is persisted with the unfinished-transaction entry so recovery keeps it across launches.
288
+ Entries written before this release carry only a token and still load, falling back to it.
289
+
290
+ - **`NativeTransaction.subscription` — typed renewal detail.** `state` (including
291
+ `in-grace-period` and `in-billing-retry`), `autoRenewing`, `renewsAt`, `renewalProductId`
292
+ and `expiresAt`, previously reachable only untyped on `raw`. iap passes these through and
293
+ does not interpret them — no subscription state machine (PLAN.md §14).
294
+
295
+ `null` on Android, where the plugin exposes no state, no renewal date and no auto-renew
296
+ flag: a renewal object full of nulls would be worse than none, and `null` correctly says
297
+ "ask the server".
298
+
299
+ - **`NativeTransaction.appUserId`** — the binding a purchase was made with, read back off the
300
+ transaction (`appAccountToken` / `obfuscatedAccountId`), so a consumer can check what a
301
+ purchase is bound to.
302
+
303
+ - **`Product.androidBasePlanId`** — the base plan an Android entry describes, reported
304
+ separately now that `Product.id` is always the product id you configured. `null` on iOS.
305
+
306
+ - New exported types: `StorePurchase`, `SubscriptionOffer`, `SubscriptionOfferType`,
307
+ `SubscriptionOfferPayment`, `SubscriptionRenewal`, `SubscriptionState`.
308
+
309
+ ### Fixed
310
+
311
+ - **Free trials were going to be reported as pay-as-you-go.** `@capgo/native-purchases`
312
+ labels its `paymentMode` integers "SKProductDiscount.PaymentMode-compatible: freeTrial=0,
313
+ payUpFront=1, payAsYouGo=2", but Apple's actual order is payAsYouGo=0, payUpFront=1,
314
+ freeTrial=2 — the comment is wrong about Apple, and both of capgo's platforms use capgo's
315
+ own order. iap maps from the emitted values, with a test pinning the legend so nobody
316
+ "corrects" it to Apple's — and, because a unit test can only pin iap against iap's own
317
+ mocks, a second guard reads capgo's shipped Swift and Java so a change *there* fails CI too.
318
+ Caught before shipping; no released version was affected.
319
+
320
+ - **Android subscriptions were reported as one-time products, under the wrong id.** capgo sets
321
+ a product payload's `identifier` to the **base plan** id and `planIdentifier` to the Play
322
+ **product** id, while iap queries by product id — so the type lookup missed on every Android
323
+ subscription, fell through to `'product'`, and returned a `Product` whose `id` was a base
324
+ plan the consumer never configured. `getProducts().find(p => p.id === configuredId)` could
325
+ not match it. `Product.id` and `type` are now always what you configured, with the base plan
326
+ reported as `androidBasePlanId`.
327
+
328
+ Relatedly, capgo emits **one payload per offer** on a base plan, and only the base-plan entry
329
+ carries the offers — so `getProducts()` could return several products sharing one id, most
330
+ with no offers. Those are now collapsed to one per configured product, preferring the base
331
+ plan named by `androidPlanId` and the entry that actually has the offers. A payload matching
332
+ no configured product is dropped rather than mistyped.
333
+
334
+ - **One unreadable entry in the unfinished-transaction store wiped the whole list.** Entries
335
+ were validated as a single array, so one malformed record failed the parse and cleared every
336
+ pending recovery with it — abandoning purchases that were paid for and never acknowledged,
337
+ which on Android Google refunds after three days. Entries are now validated individually:
338
+ the bad one is dropped and logged, its neighbours survive.
339
+
340
+ - **`appUserId` never compared equal to what was passed in, on iOS.** StoreKit returns
341
+ `UUID.uuidString`, which Swift renders uppercase, while `crypto.randomUUID()` and iap's
342
+ case-insensitive validator both deal in lowercase. The binding is now lower-cased, so
343
+ `purchase.appUserId === suppliedAppUserId` holds — the one comparison the field exists for.
344
+
345
+ - **Two lookup tables resolved inherited object properties.** `subscriptionState` and a
346
+ subscription period's `unitString` arrive as strings off a native bridge and were looked up
347
+ in plain object literals, so a value of `"constructor"` returned a function rather than
348
+ missing: `state` could be a function at runtime while typed as a string union, and a period
349
+ could render as `"P1function Object() { [native code] }"`. Both are `Map`s now. Not
350
+ reachable from the store's own enums — but both functions are documented as total, and were
351
+ not.
352
+
353
+ - **A price the store did not send became the string `"NaN"`.** `priceMicros` is `"0"` for a
354
+ non-finite price now; `priceString` remains the value to display.
355
+
356
+ - **The installation guide said `@capgo/native-purchases` is MIT-licensed. It is MPL-2.0.**
357
+ Corrected on both the `8.x` and the archived `v7` installation pages, which were the first
358
+ place a consumer would have read it. iap uses the plugin unmodified, so the correction
359
+ changes no obligation — but the stated licence is now the actual one. The stale
360
+ `Cap-go/native-purchases` repository links were refreshed to
361
+ `Cap-go/capacitor-native-purchases` on the same lines.
362
+
363
+ - **The configuration guide told you to do something that throws.** It said to register one
364
+ Play subscription product's several base plans as separate `ConfiguredProduct` entries
365
+ "with the same `id`" — which `createIAP()` rejects with `INVALID_CONFIG: Duplicate product
366
+ id`. It now describes what actually works: one Play product per cadence, or one entry
367
+ addressing one base plan via `androidPlanId`.
368
+
369
+ - **Documented Android limitations that were silently wrong rather than absent.**
370
+ `promotionalOffers` on Android reports the base plan's *standard recurring* price, not the
371
+ promotion's — so a free-trial promo arrives as `pay-as-you-go` at full price, and the docs
372
+ now say not to render those terms there. `periodCount` is usually `0` on Android for any
373
+ offer, because Play reports `getBillingCycleCount()` as `0` for both non-recurring and
374
+ infinitely recurring phases. A period is single-component on Android (`P1M15D` → `P1M`).
375
+
376
+ - **The installation guide said `@capgo/native-purchases` is MIT-licensed. It is MPL-2.0.**
377
+ Corrected on both the `8.x` and the archived `v7` installation pages, which were the first
378
+ place a consumer would have read it. iap uses the plugin unmodified, so the correction
379
+ changes no obligation — but the stated licence is now the actual one. The stale
380
+ `Cap-go/native-purchases` repository links were refreshed to
381
+ `Cap-go/capacitor-native-purchases` on the same lines.
382
+
383
+ ### Fixed
384
+
385
+ - **Android purchases could never verify, and `restorePurchases()` always threw.** Google's
386
+ verification APIs are keyed on the app's package name, and the native layer does not report
387
+ it — so `verifyNativeTransaction` and the restore request builder both threw `STORE_ERROR`
388
+ for *every* Google transaction. Every Android purchase returned `verification_failed` and
389
+ every restore rejected.
390
+
391
+ This predates this release and shipped: it was never caught because every Google test
392
+ fixture hand-set the `packageName` field the real adapter never sets. Set
393
+ `config.android.packageName` to your applicationId; `initialize()` warns on Android when it
394
+ is missing, and the error messages now name the setting.
395
+
396
+ - **A purchase whose acknowledgement failed stopped being tracked.** `purchase()` removed the
397
+ unfinished-transaction entry even when `acknowledge()` threw, on the reasoning that "a later
398
+ restore()/refresh() re-acks it" — but only `restore()` acknowledges, and nothing calls it
399
+ automatically. So a paid-for purchase was granted, left unacknowledged, and forgotten, right
400
+ up to Google's silent 3-day auto-refund. The entry is now retained for recovery to retry.
401
+
402
+ - **Android would have reported every pending purchase as `cancelled` while it was still
403
+ processing.** The pending-state check looked for Play `purchaseState === '0'`, following the
404
+ plugin's own type declarations, which document `"0": Payment pending`. That is wrong: Play's
405
+ `Purchase.PurchaseState` is `UNSPECIFIED_STATE = 0, PURCHASED = 1, PENDING = 2`, and
406
+ `getPurchaseState()` only ever returns 1 or 2 — so the filter matched nothing and every
407
+ tracked pending purchase read as vanished. Worse, the marker was then cleared, so when the
408
+ payment really cleared the `purchased` event never fired either.
409
+
410
+ Now expressed as "the store has it but does not call it owned", so `UNSPECIFIED_STATE` and
411
+ any state Play adds later also read as not-yet-granted. Erring towards "still pending"
412
+ self-corrects on the next pass; erring towards "cancelled" is permanent. A contract test
413
+ pins that the plugin still passes Play's own integer through rather than translating it.
414
+
415
+ - **`purchase-pending-resolved` could fire twice, with two verifies and two acknowledges.**
416
+ The resolution did a read-then-write across an `await`, and reconciliation had no
417
+ re-entrancy guard — and both overlaps are reachable: the transaction listener is
418
+ fire-and-forget, and the resume listener does not debounce, so two foreground events ran two
419
+ reconciliations. On Android the second acknowledge of an already-acked token errors, which
420
+ the settle path reads as a failure and retains the entry for. Now a single atomic
421
+ take-if-present, a single-flight reconciliation, and a per-token settle guard.
422
+
423
+ - **A pending purchase settled by any other path was later reported `cancelled`.** Only the
424
+ update orchestrator cleared the marker, so a purchase finished by a `purchase()` retry, by
425
+ `restore()`, or by launch recovery left it behind — and the next reconcile found it neither
426
+ owned nor pending and announced that it had failed. Resolution now happens inside the
427
+ settle path itself, so every route to a settled transaction clears the mark.
428
+
429
+ - **An out-of-band transaction could never be acknowledged on iOS, and never resolve.** The
430
+ native layer calls `transaction.finish()` *before* notifying listeners, and its
431
+ `acknowledgePurchase` then looks the transaction up in `Transaction.all` and rejects
432
+ "already finished" — which `Transaction.all` guarantees for a finished consumable. That read
433
+ as a settlement failure. An already-finished acknowledge is now success **on that path
434
+ only**; a real acknowledge failure on Android is still a failure.
435
+
436
+ - **Refunds and revocations accumulated in the recovery store forever.** They verify
437
+ `valid:false` with a code no permanent-error list knows, so they were retained and
438
+ re-verified at every launch, eating the recovery batch that entries which can still succeed
439
+ need. A revocation is no longer persisted — there is nothing to recover from.
440
+
441
+ - **Reconciliation could report `cancelled` for a purchase that had just cleared.** The owned
442
+ and pending snapshots are two separate native round-trips, and reading owned first meant a
443
+ purchase that cleared in between appeared in neither. Pending is read first now, so that
444
+ window resolves to "still pending".
445
+
446
+ - **A transaction arriving during or after `destroy()` repopulated the torn-down instance**,
447
+ writing through the shared state closures and the entitlement cache after teardown.
448
+
449
+ - **`reconcilePending()` ran inside `initialize()`**, so a tracked pending purchase delayed
450
+ `ready` by a store round-trip plus a backend verify, uncapped. It runs just after `ready`
451
+ now.
452
+
453
+ The local-verification work in this release went through a review pass that found six ways it
454
+ could grant or revoke wrongly. All are fixed, and all are worth naming because three of them
455
+ broke the invariant this release advertises as untouched:
456
+
457
+ - **Restore acknowledged transactions the local check had refused.** The adapter reported a
458
+ single consolidated `valid: true`, so the orchestrator acknowledged the whole owned set —
459
+ including purchases just declared invalid, which on Android finalises them and waives Play's
460
+ refund window. `RestoreResponse` gained an optional `verifiedTokens`, and only those are
461
+ acknowledged.
462
+
463
+ - **Recovery re-granted exactly what a purchase had refused.** Recovery re-verifies from the
464
+ *stored entry*, which kept no binding, no expiry and no purchase state — so an on-device
465
+ check saw an amputated transaction and answered differently than it had at purchase time. A
466
+ refused expired subscription came back one launch later and was granted as *never
467
+ expiring*. The mirror case was as bad: with `verification.appUserId` set, a legitimate
468
+ purchase failed `BINDING_MISMATCH` forever, was never acknowledged, and Google refunded it.
469
+ The entry now persists what a verification needs.
470
+
471
+ - **A successful purchase replaced the whole entitlement set with that one purchase's.** The
472
+ flows treat a verify response as consolidated, as a backend's is. Buying a consumable
473
+ silently revoked a subscription, in memory and on disk, until the next refresh. Local mode
474
+ now returns the consolidated set.
475
+
476
+ - **An empty answer from the store wiped everything.** Play resolves its owned-purchases query
477
+ with an empty array when the query *fails* — disconnected service, signed-out account — so
478
+ "owns nothing" and "could not tell you" are identical. `refresh()` treated it as
479
+ authoritative and overwrote the cache, while stamping the entitlements as freshly confirmed.
480
+ Local mode now keeps the current set; a downgrade needs an expiry or an explicit revocation.
481
+
482
+ - **iOS refunds and revocations passed the check.** `Transaction.all` retains revoked
483
+ transactions and the platform reports the fact locally — so a refunded non-expiring purchase
484
+ granted forever, having no expiry to fall back on. Now refused, with a `REVOKED` reason.
485
+
486
+ - **The entitlement mapper's output was trusted blindly.** Unlike a backend response it went
487
+ through no schema, so a mapper returning a malformed object cached an entitlement that
488
+ `hasEntitlement` could never match and the next launch silently dropped — and one returning a
489
+ non-array threw a raw `TypeError` out of `purchase()` *after* acknowledging. Validated now;
490
+ a bad mapping grants nothing and logs.
491
+
492
+ Smaller ones from the same pass: a duplicated entitlement key kept the first derived rather
493
+ than the longest-lasting, so a live renewal lost to an older one inside the grace window; two
494
+ lookup tables resolved inherited object properties; a numeric Play purchase state skipped its
495
+ check; `reset()` emitted an empty `previous` on the one event that removes everything, and
496
+ emitted at all when there was nothing to remove.
497
+
498
+ ### Changed
499
+
500
+ - **`NativeAdapter` gained two optional methods**, `watchTransactions` and
501
+ `getPendingProductIds`. Internal contract; both are optional, so an adapter that omits them
502
+ degrades to reconciliation. `selectNativeAdapter()` now takes a logger, which the capgo
503
+ adapter uses for the best-effort paths that swallow their own failures.
504
+
505
+ - **`NativeTransaction` gained a required field.** No consumer can produce one — there is no
506
+ config hook for a custom native adapter, and nothing public returns the type — so this is
507
+ additive for anything that only reads one. It is exported, though, so code that *constructs*
508
+ a `NativeTransaction` (a hand-rolled test double, a cast target) needs `purchaseReference`
509
+ added. Prefer `StorePurchase` for anything consumer-facing.
510
+
511
+ - **`NativeAdapter.getProducts()` now takes whole `ConfiguredProduct` entries** rather than
512
+ `{ id, type }` pairs, because `androidPlanId` is what picks a base plan on Android. Internal
513
+ contract; no public surface changes.
514
+
515
+ ### Documentation
516
+
517
+ - **A documented audit of the native layer** — [The native layer](https://iap.nossdev.com/guide/native-layer)
518
+ records what `@capgo/native-purchases` guarantees and what it does not, so the boundary
519
+ that caps iap's feature set is written down rather than rediscovered. Audited against
520
+ `8.6.5`: Play Billing Library **8.3.0** (ahead of Google's Billing-8 requirement, so
521
+ nothing to upgrade), licence **MPL-2.0** used unmodified (no copyleft obligation
522
+ propagates to iap or its consumers), and **no device or advertising identifiers** in
523
+ either platform's native source, with an empty `AndroidManifest.xml` that declares no
524
+ permissions of its own.
525
+
526
+ Two caveats the page documents because a clean bill of health would have hidden them:
527
+ the Android plugin **writes purchase tokens to logcat in the clear**, ungated (it redacts
528
+ the account identifier, so this reads as an oversight) — iap masks tokens in its own logs,
529
+ but that rule stops at the bridge; and when `androidPlanId` matches no offer, the plugin
530
+ **falls back to the first available base plan instead of failing**, so a stale plan id
531
+ silently purchases something else.
532
+ - **Regression guards over the installed plugin** (`tests/unit/native-plugin-contract.test.ts`).
533
+ They read the plugin's own Gradle file, `package.json`, native sources and type
534
+ declarations, so a bump that invalidates a documented guarantee fails CI instead of
535
+ quietly making the docs wrong. The known gaps — no Play purchase signature, no
536
+ `originalTransactionId` field, no intro-offer eligibility query — are asserted
537
+ *inverted*, so capgo adding one of them also fails, as the signal to light up the
538
+ matching iap capability.
7
539
 
8
540
  ## [8.0.0] — 2026-09-04
9
541
 
package/README.md CHANGED
@@ -64,6 +64,8 @@ await iap.purchase({
64
64
  - [Getting started](https://iap.nossdev.com/guide/getting-started) — first purchase in 30 minutes
65
65
  - [Backend contract](https://iap.nossdev.com/guide/backend-contract) — four endpoints your backend implements
66
66
  - [Architecture](https://iap.nossdev.com/guide/architecture) — three-tier model
67
+ - [The native layer](https://iap.nossdev.com/guide/native-layer) — what `@capgo/native-purchases` exposes, its licence, its privacy audit
68
+ - [Offline and no-backend use](https://iap.nossdev.com/guide/offline-and-no-backend) — on-device verification, offline guarantees, privacy
67
69
  - [Vue + Quasar recipe](https://iap.nossdev.com/recipes/vue-quasar) / [React recipe](https://iap.nossdev.com/recipes/react)
68
70
 
69
71
  ## Why this library
@@ -71,31 +73,52 @@ await iap.purchase({
71
73
  `@nosslabs/iap` does **one thing**: orchestrate the purchase flow on the client. It
72
74
 
73
75
  - wraps [`@capgo/native-purchases`](https://github.com/Cap-go/native-purchases) for native purchase + restore,
74
- - POSTs to **your** backend (which calls Attesto) for receipt validation,
76
+ - POSTs to **your** backend (which calls Attesto) for receipt validation — or [verifies on the device](https://iap.nossdev.com/guide/offline-and-no-backend) when you have no backend at all,
75
77
  - acknowledges native transactions only **after** the backend confirms — `autoAcknowledgePurchases: false` defers finishing on **both** iOS and Android, so there's no phantom grant and no iOS finish-before-verify race,
76
78
  - caches entitlements locally for instant, reactive UI reads,
77
79
  - recovers unfinished transactions across app launches.
78
80
 
79
- It does **not**: talk to Attesto directly, define entitlement business logic, manage user auth, or ship paywall UI. Those belong to your app and your backend.
81
+ It does **not**: talk to Attesto directly, manage user auth, or ship paywall UI. Those belong to your app and your backend. It does not define entitlement business logic either — except in local mode, where you supply the mapping in config because there is no backend to ask.
80
82
 
81
83
  ## Capacitor support matrix
82
84
 
83
85
  | `@nosslabs/iap` | Capacitor | Native plugin | dist-tag | Status |
84
86
  |---|---|---|---|---|
85
- | 8.x | 8.x | `@capgo/native-purchases ^8` | `@latest`, `@latest-8` | **Current** — `8.0.0` |
87
+ | 8.x | 8.x | `@capgo/native-purchases ^8.2` | `@latest`, `@latest-8` | **Current** — `8.2.0` |
86
88
  | 7.x | 7.x | `@capgo/native-purchases@lts-v7` | `@latest-7` (pin via `^7`) | Maintenance |
87
89
  | 5.x | 5.x | `cordova-plugin-purchase ^13.x` | `@latest-5` (pin via `^5`) | Maintenance |
88
90
 
89
- ## Optional peer dependency
91
+ ## Optional peer dependencies
90
92
 
91
- If you want auto-refresh on app resume (default behavior):
93
+ Both are reached through a lazy `import()`, so an app that does not use them never loads them
94
+ and keeps them out of its bundle. If you *uninstall* one, externalise it in your bundler
95
+ (`build.rollupOptions.external` in Vite) — a dynamic import is still resolved at build time.
96
+ See the [installation guide](https://iap.nossdev.com/guide/installation).
97
+
98
+ **`@capacitor/app`** — auto-refresh on app resume (the default behaviour):
92
99
 
93
100
  ```bash
94
101
  npm install @capacitor/app
95
102
  npx cap sync
96
103
  ```
97
104
 
98
- Or disable the listener with `options.refreshOnResume: false`. See [installation guide](https://iap.nossdev.com/guide/installation#optional-app-resume-listener).
105
+ Or disable the listener with `options.refreshOnResume: false`.
106
+
107
+ **`@capacitor/preferences`** — the entitlement cache and unfinished-transaction storage, under
108
+ the default `storage.type: 'preferences'`:
109
+
110
+ ```bash
111
+ npm install @capacitor/preferences
112
+ npx cap sync
113
+ ```
114
+
115
+ Skip it if you set `storage.type` to `'custom'` (your own adapter) or `'memory'`. Choosing
116
+ `'preferences'` without it fails at `initialize()` with a message saying so, rather than
117
+ silently persisting nothing.
118
+
119
+ If you use the default `'preferences'` and never declared the package yourself, add it to your
120
+ `dependencies` now: npm installed it for you while it was a *required* peer, and that will stop
121
+ happening.
99
122
 
100
123
  ## Development
101
124
 
@@ -112,3 +135,8 @@ npm run docs:dev # vitepress dev (http://localhost:5173)
112
135
  ## License
113
136
 
114
137
  MIT — see [LICENSE](./LICENSE).
138
+
139
+ The native layer, [`@capgo/native-purchases`](https://github.com/Cap-go/capacitor-native-purchases),
140
+ is **MPL-2.0** and is used **unmodified**, so its file-level copyleft imposes no obligation on
141
+ iap or on your app's source. [The native layer](https://iap.nossdev.com/guide/native-layer)
142
+ has the full note, with the Play Billing version and the device-identifier audit.