@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 +476 -1
- package/README.md +9 -2
- package/dist/index.cjs +1911 -412
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +905 -217
- package/dist/index.d.ts +905 -217
- package/dist/index.js +1910 -413
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
-
## [
|
|
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,
|
|
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.
|