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