@chipmobilesdk/rn-payment 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,761 +1,885 @@
1
- # @chipmobilesdk/rn-payment
2
-
3
- Reusable in-app payment and entitlement capability for ChipMobileSdk-family React
4
- Native apps. Android only in 0.1.0, via Google Play Billing, behind a contract
5
- that is neutral with respect to platform and store.
6
-
7
- **The package owns capability. Your app owns policy.** The package gives you a
8
- safe, consistent way to sell, finalize, restore, and report entitlements. What you
9
- sell, at what price, on which screen, at which moment, what each entitlement
10
- unlocks, and how generous your grace policy is — all yours. There is no product
11
- identifier, no screen name, and no business rule in this package.
12
-
13
- ---
14
-
15
- ## Contents
16
-
17
- - [The one idea that matters](#the-one-idea-that-matters)
18
- - [Install](#install)
19
- - [Compatibility](#compatibility)
20
- - [Quick start](#quick-start)
21
- - [Catalog and entitlement mapping](#catalog-and-entitlement-mapping)
22
- - [The entitlement model](#the-entitlement-model)
23
- - [Purchase flow](#purchase-flow)
24
- - [Finalization: the invariant you cannot opt out of](#finalization-the-invariant-you-cannot-opt-out-of)
25
- - [Consumables](#consumables)
26
- - [Subscriptions](#subscriptions)
27
- - [Prices and offers](#prices-and-offers)
28
- - [Sync, restore, and offline](#sync-restore-and-offline)
29
- - [Environments and the production guard](#environments-and-the-production-guard)
30
- - [Server-side validation](#server-side-validation)
31
- - [Localizable copy](#localizable-copy)
32
- - [Events and errors](#events-and-errors)
33
- - [Wiring an entitlement to another system](#wiring-an-entitlement-to-another-system)
34
- - [Testing](#testing)
35
- - [What is stored on the device](#what-is-stored-on-the-device)
36
- - [Trust boundary](#trust-boundary)
37
- - [Play Console checklist](#play-console-checklist)
38
- - [Play policy checklist](#play-policy-checklist)
39
- - [Production release checklist](#production-release-checklist)
40
- - [Known limitations](#known-limitations)
41
- - [Adding iOS later](#adding-ios-later)
42
- - [Versioning](#versioning)
43
-
44
- ---
45
-
46
- ## The one idea that matters
47
-
48
- Your business logic asks about **entitlement keys** you chose — `no_ads`,
49
- `pro_features` — and never about store product ids, product types, or which store
50
- sold the thing.
51
-
52
- ```ts
53
- const { status } = useEntitlement('no_ads');
54
- ```
55
-
56
- `status` is `'unknown' | 'entitled' | 'not_entitled'`. The third state is why this
57
- package exists: on a cold start we do not yet know whether this user paid, and
58
- saying so is strictly better than guessing. If you collapse `unknown` into
59
- `not_entitled`, a paying user watches ads flash before they vanish, or sees a
60
- purchased feature locked for a second. Render a loading treatment for `unknown`.
61
-
62
- ---
63
-
64
- ## Install
65
-
66
- ```bash
67
- npm install @chipmobilesdk/rn-payment
68
- ```
69
-
70
- Peer dependencies you must install yourself:
71
-
72
- ```bash
73
- npm install react-native-iap react-native-mmkv react-native-nitro-modules
74
- ```
75
-
76
- - `react-native-iap` — the Play Billing binding. Reached only through this
77
- package's Android adapter; you never import it.
78
- - `react-native-mmkv` — synchronous local storage. Required: it is what lets
79
- entitlement reads be synchronous on a render path. Without it entitlements do
80
- not survive a cold start and read `unknown` until the first sync, which is safe
81
- but degraded.
82
- - `react-native-nitro-modules` — transitive requirement of `react-native-iap`.
83
-
84
- > **Android manifest.** `com.android.vending.BILLING` is declared by the billing
85
- > library's own `AndroidManifest.xml` and merged into your app automatically. You
86
- > do **not** add it yourself. No runtime permission is requested and no sensitive
87
- > API is touched, so there is no permission prompt and no denial path to handle.
88
- >
89
- > **Verified state in this repository:** the app manifest at
90
- > `android/app/src/main/AndroidManifest.xml` declares `INTERNET`, `CAMERA`, and
91
- > notification permissions, and **no** `BILLING` entry — correct, because the
92
- > billing library is not installed yet. Once it is, confirm the merged manifest
93
- > contains exactly one `com.android.vending.BILLING` entry and no new runtime
94
- > permission:
95
- >
96
- > ```bash
97
- > # After a debug build, inspect the merge report:
98
- > cat android/app/build/outputs/logs/manifest-merger-debug-report.txt | grep -i billing
99
- > ```
100
-
101
- ---
102
-
103
- ## Compatibility
104
-
105
- Verified against this repository's toolchain, not assumed:
106
-
107
- | Item | Supported |
108
- |---|---|
109
- | React | >= 19 (verified 19.2.3) |
110
- | React Native | >= 0.85 (verified 0.85.3), New Architecture only |
111
- | TypeScript | >= 5.8 |
112
- | Node | >= 22.11.0 |
113
- | Android `minSdk` / `compileSdk` / `targetSdk` | 24 / 36 / 36 |
114
- | Kotlin | 2.1.20 |
115
- | `react-native-iap` | >= 16.6.1 < 17 |
116
- | `react-native-mmkv` | >= 4.0.0 |
117
- | `react-native-nitro-modules` | >= 0.36.5 |
118
-
119
- **Why the `react-native-iap` ceiling is the next major.** Both the Nitro peer
120
- requirement and the bundled Play Billing version move on majors, so a major bump
121
- needs re-verification rather than silent adoption.
122
-
123
- > ⚠️ **The Android adapter is written but not yet verified against an installed
124
- > binding.** Its call shapes follow `react-native-iap` 16.x as documented, and the
125
- > surface it depends on is declared explicitly in a local `IapModule` interface so
126
- > a mismatch fails at typecheck rather than at runtime on a user's device. Verify
127
- > it on a device before shipping.
128
-
129
- ---
130
-
131
- ## Quick start
132
-
133
- ```tsx
134
- import {
135
- PaymentProvider,
136
- createStoreProvider,
137
- useEntitlement,
138
- usePurchase,
139
- } from '@chipmobilesdk/rn-payment';
140
-
141
- const config = {
142
- catalog: [
143
- {
144
- productKey: 'remove_ads',
145
- productType: 'one_time_permanent',
146
- storeProductIds: { android: 'com.yourapp.remove_ads' },
147
- grants: ['no_ads'],
148
- },
149
- ],
150
- entitlements: ['no_ads'],
151
- environment: 'production',
152
- isProductionBuild: true,
153
- };
154
-
155
- export function App() {
156
- return (
157
- <PaymentProvider config={config} deps={{ provider: createStoreProvider() }}>
158
- <YourApp />
159
- </PaymentProvider>
160
- );
161
- }
162
-
163
- function AdSlot() {
164
- const noAds = useEntitlement('no_ads');
165
-
166
- // Hold while unknown. Do not guess.
167
- if (noAds.status === 'unknown') return null;
168
- if (noAds.status === 'entitled') return null;
169
- return <YourBanner />;
170
- }
171
- ```
172
-
173
- `createStoreProvider()` picks the platform adapter. On a platform with no adapter
174
- it returns a stub that reports `unavailable` instead of throwing, so your app
175
- still starts and runs — it only loses the ability to sell.
176
-
177
- **Call `handleForeground()` when your app returns to the foreground.** It is left
178
- to you rather than subscribed internally, because this package must not decide
179
- what "foreground" means for an app that may be backgrounded by its own modal, a
180
- biometric prompt, or the store's own purchase sheet. It is rate-limited
181
- regardless, so calling it liberally is safe.
182
-
183
- ---
184
-
185
- ## Catalog and entitlement mapping
186
-
187
- The catalog is the whole of your product configuration, and the only place a store
188
- product id appears.
189
-
190
- ```ts
191
- catalog: [
192
- {
193
- productKey: 'premium_monthly', // yours
194
- productType: 'subscription',
195
- storeProductIds: {
196
- android: 'com.yourapp.premium.monthly', // Play Console
197
- ios: 'com.yourapp.premium.monthly', // optional, additive
198
- },
199
- grants: ['no_ads', 'pro_features'], // yours
200
- },
201
- ]
202
- ```
203
-
204
- **The mapping is many-to-many.** Several products may grant one entitlement — a
205
- monthly plan, a yearly plan, and a lifetime unlock all granting `no_ads` — and one
206
- product may grant several. An entitlement stays valid while **any** granting
207
- product remains valid, so losing one source does not revoke it.
208
-
209
- Validation runs at configure time, before anything connects, and names the
210
- offending entry. It catches duplicate product keys, a missing store id for the
211
- current platform, an invalid product type, a product granting nothing, two
212
- products sharing a store id, an entitlement no product grants, and an entitlement
213
- granted but never declared. All problems are reported in one pass.
214
-
215
- ### Creating the products in Play Console
216
-
217
- 1. Create each product with the exact id you put in `storeProductIds.android`.
218
- 2. One-time products go under **In-app products**; consumables are also in-app
219
- products — the *package* decides consumable behaviour via `productType`, not
220
- Play.
221
- 3. Subscriptions need a **base plan**, and an **offer** if you want a trial or
222
- introductory price.
223
- 4. Activate each product. An inactive product is reported as
224
- `not_available_from_store`, distinctly from a network error.
225
-
226
- ---
227
-
228
- ## The entitlement model
229
-
230
- ```ts
231
- const state = engine.getEntitlement('no_ads');
232
- // { key, status, sources, subscription?, lastSyncedAt?, isStale }
233
- ```
234
-
235
- | Status | Meaning | What to render |
236
- |---|---|---|
237
- | `unknown` | Not yet known, or the cached answer went stale | A loading or held treatment |
238
- | `entitled` | At least one valid source grants it | The paid experience |
239
- | `not_entitled` | Synced, fresh, and nothing grants it | The unpaid experience |
240
-
241
- `unknown` is **re-entrant**, not just an initial state: it returns when cached
242
- data passes its staleness ceiling. Handle it as a real state, not a startup
243
- flicker.
244
-
245
- `sources` names *your* product keys, so you can show "You're on the yearly plan"
246
- without touching a transaction record.
247
-
248
- Changes are pushed. You never poll.
249
-
250
- ---
251
-
252
- ## Purchase flow
253
-
254
- ```ts
255
- const result = await engine.purchase('premium_monthly', {
256
- // Optional. Passed straight to the store for fraud prevention and never
257
- // generated, inferred, or persisted by this package.
258
- obfuscatedAccountId: yourOpaqueAccountId,
259
- offerKey: selectedOffer.offerKey,
260
- });
261
- ```
262
-
263
- `result.kind` is a discriminated union, and the distinctions matter:
264
-
265
- | Kind | What it means | Suggested handling |
266
- |---|---|---|
267
- | `success` | Paid and granted | Show the unlocked state |
268
- | `pending` | Slow payment method; **not granted** | Show a waiting notice |
269
- | `user_cancelled` | Normal. Not an error | Say nothing |
270
- | `already_owned` | Store says they own it; a re-sync was triggered | Show the unlocked state |
271
- | `ineligible` | Not eligible for this offer | Offer an alternative |
272
- | `unknown_product` | Not in your catalog | Developer bug |
273
- | `product_unavailable_in_region` | Not sold where they are | Explain |
274
- | `disabled` | Your global flag is off | Hide the entry point |
275
- | `not_ready` | Not initialized or connecting | Retry later |
276
- | `purchase_in_progress` | Another flow is open | Ignore the second tap |
277
- | `environment_blocked` | Non-production build refused a real charge | Developer-facing |
278
- | `validation_rejected` | Your backend said no | Explain |
279
- | `timeout` / `network_error` / `store_error` | Transient or store-side | Offer retry |
280
-
281
- **Never report `user_cancelled` as a failure.** Showing an error banner to someone
282
- who simply changed their mind is the most common paywall bug this union exists to
283
- prevent.
284
-
285
- The purchase surface is rendered by the store. This package neither controls,
286
- customizes, nor can verify its accessibility — it only reports what happened.
287
-
288
- ---
289
-
290
- ## Finalization: the invariant you cannot opt out of
291
-
292
- Google **auto-refunds and revokes** a purchase that is not acknowledged within
293
- three days. So acknowledgement is not a step you can forget here: the package
294
- owns it.
295
-
296
- The ordering that makes it safe is: the intent to finalize is written to local
297
- storage **before** the store call is attempted. A crash before the write leaves
298
- the purchase still owned, so the next sync re-queues it. A crash after the write
299
- leaves a queue record, so the next startup drains it. There is no window where a
300
- paid transaction is forgotten.
301
-
302
- Failed attempts retry under bounded backoff. Exhausting a session's budget is
303
- **not** giving up — the record stays queued and the next launch tries again, which
304
- is how the package stays inside the three-day window without hammering a store
305
- that is currently refusing.
306
-
307
- ```ts
308
- // Observable rather than swallowed.
309
- engine.getUnfinalized(); // [{ productKey, transactionId, attempts, purchasedAt }]
310
- ```
311
-
312
- Entitlement is granted **before** finalization completes, deliberately: a store
313
- hiccup must never withhold something already paid for.
314
-
315
- ---
316
-
317
- ## Consumables
318
-
319
- For a consumable, **you** decide when it counts as consumed, because only you know
320
- whether the item was actually banked in your own data.
321
-
322
- ```ts
323
- const result = await engine.purchase('coin_pack_100');
324
- if (result.kind === 'success') {
325
- await yourDatabase.addCoins(100); // bank it first
326
- await engine.consume(result.transaction); // then tell the store
327
- }
328
- ```
329
-
330
- Until you call `consume`, the store will not allow a repurchase. Consuming twice,
331
- or concurrently, grants the item exactly once. A consumable never becomes a
332
- permanent entitlement.
333
-
334
- ---
335
-
336
- ## Subscriptions
337
-
338
- ```ts
339
- const state = engine.getEntitlement('pro_features');
340
- state.subscription;
341
- // { expiresAt?, willAutoRenew, isCancelledButValid, renewalState }
342
- ```
343
-
344
- A successful renewal changes nothing, so it emits **no** event — change signals
345
- come from a diff, which makes that structural rather than a special case.
346
-
347
- ### Grace and hold policy
348
-
349
- ```ts
350
- gracePolicy: 'grant', // default
351
- holdPolicy: 'withhold', // default
352
- ```
353
-
354
- A **grace period** means the store is still retrying a payment method that failed
355
- for reasons usually outside the user's control. Revoking immediately punishes a
356
- paying customer for a billing hiccup. **Account hold** means the store has given
357
- up retrying; the subscription is effectively unpaid.
358
-
359
- > **Android cannot separate these client-side.** Play returns a grace-period
360
- > subscription as active and stops returning a held one — making hold
361
- > indistinguishable from expired, cancelled, or refunded. So `renewalState`
362
- > reports `indeterminate` rather than inventing the distinction.
363
- >
364
- > **The defaults still work with no backend**, because Play's own behaviour
365
- > matches them: a grace-period purchase keeps being returned (so the entitlement
366
- > holds) and a held one stops being returned (so it lapses at the next sync). Only
367
- > a *non-default* policy needs server data, through the validation hook.
368
-
369
- ### Plan changes
370
-
371
- ```ts
372
- await engine.changePlan('premium_monthly', 'premium_yearly', 'immediate_with_time_proration');
373
- ```
374
-
375
- | Mode | Effect |
376
- |---|---|
377
- | `immediate_with_time_proration` | Switch now, credit unused time. Usual upgrade. |
378
- | `immediate_and_charge_full_price` | Switch now, charge full price, restart the period. |
379
- | `immediate_and_charge_prorated_price` | Switch now, charge the difference. |
380
- | `deferred` | Keep the current plan until it expires. Usual downgrade. |
381
-
382
- A plan change goes through the normal purchase flow, so it inherits the
383
- single-flight lock, the environment guard, validation, and the finalization queue.
384
-
385
- ### Cancellation
386
-
387
- ```ts
388
- await engine.openSubscriptionManagement('premium_monthly');
389
- ```
390
-
391
- This opens the **store's** screen. Platforms forbid an app presenting its own
392
- cancellation screen, so there is deliberately no alternative in this package.
393
-
394
- ---
395
-
396
- ## Prices and offers
397
-
398
- ```ts
399
- const { products, missing } = await engine.getProducts(['premium_monthly']);
400
- ```
401
-
402
- **Render `formattedPrice`. Never build a price string.** Only the store knows the
403
- right currency symbol, its placement, the decimal separator, and the grouping for
404
- the user's locale — `1.990 ₫` and `$19.90` are not the same shape. The
405
- `priceAmountMicros` field exists for sorting and comparison only.
406
-
407
- Offer eligibility is **the store's verdict**. Computing it locally would promise a
408
- trial the store then refuses at checkout.
409
-
410
- `missing` separates `not_in_catalog` (your configuration) from
411
- `not_available_from_store` (unapproved, or not sold in this country), and neither
412
- is conflated with a network error — so you can explain an absent row instead of
413
- rendering a blank.
414
-
415
- Results are cached in memory for 15 minutes by default, never persisted, and
416
- `refreshProducts` bypasses the cache.
417
-
418
- ---
419
-
420
- ## Sync, restore, and offline
421
-
422
- Sync runs automatically at initialization and when you call `handleForeground()`,
423
- rate-limited to once a minute by default.
424
-
425
- ```ts
426
- const outcome = await engine.restore();
427
- if (outcome.ok && outcome.restored) { /* purchases came back */ }
428
- else if (outcome.ok) { /* nothing found — say that, not "success" */ }
429
- ```
430
-
431
- **"Nothing found" is a distinct success.** Telling a user their purchases were
432
- restored when none existed is a support ticket.
433
-
434
- Entitlements are readable offline from the last successful sync. Past the
435
- staleness ceiling — **7 days** by default — they become `unknown`, never
436
- `not_entitled`. Seven days covers a holiday without connectivity while bounding
437
- how long a refunded entitlement can outlive its refund.
438
-
439
- When the store stops reporting a transaction, the entitlement is revoked at the
440
- next sync. The cache never resurrects it.
441
-
442
- ### Logout and account switching
443
-
444
- ```ts
445
- await engine.clearLocalData();
446
- ```
447
-
448
- If your app has its own accounts, also supply a `scopeToken`:
449
-
450
- ```ts
451
- scopeToken: opaqueHashOfYourUserId,
452
- ```
453
-
454
- Cached entitlements are partitioned by it, so a second user cannot inherit the
455
- first user's entitlements even if you forget to call `clearLocalData`. The token is
456
- a cache-partition key — never interpreted, transmitted, or attached to a
457
- transaction — and is distinct from `obfuscatedAccountId`.
458
-
459
- ---
460
-
461
- ## Environments and the production guard
462
-
463
- ```ts
464
- environment: 'development' | 'test' | 'production',
465
- isProductionBuild: boolean, // you supply this; only you know how you built
466
- ```
467
-
468
- Two layers:
469
-
470
- 1. **Fail closed at construction.** A non-production build declaring `production`
471
- throws `PAYMENT_PRODUCTION_CONFIG_IN_NON_PRODUCTION_BUILD` and never connects.
472
- 2. **Runtime block.** Outside production, any chargeable purchase is refused,
473
- recorded in `getStatus().blockedOperations`, and emitted as
474
- `environment_blocked` — observable in state, so a test can assert it rather
475
- than scraping logs.
476
-
477
- > **Why this blocks where `@chipmobilesdk/rn-ads` substitutes.** The ads package
478
- > swaps in Google's official *test ad units* when a non-production build supplies
479
- > production ones, keeping the app working. Play has no equivalent test **product**
480
- > id — testing uses licence-tester *accounts* against real product ids — so
481
- > substitution is impossible here. Blocking with an observable refusal is the only
482
- > honest option, and the asymmetry is deliberate rather than an inconsistency.
483
-
484
- ---
485
-
486
- ## Server-side validation
487
-
488
- Optional. Without it the package is fully functional client-side.
489
-
490
- ```ts
491
- validateTransaction: async candidate => {
492
- const verdict = await fetch('https://your.api/verify', {
493
- method: 'POST',
494
- body: JSON.stringify(candidate),
495
- }).then(r => r.json());
496
- return { approved: verdict.ok, reason: verdict.reason };
497
- },
498
- onValidationUnavailable: 'grant_and_queue', // default
499
- ```
500
-
501
- **The hook runs for newly completed purchases only.** Purchases seen during sync
502
- or restore are not resubmitted — which is what keeps restore and foreground sync
503
- working while your backend is down. Revocation still flows from the store.
504
-
505
- **When the backend is unreachable, the default grants and queues.** The store has
506
- already confirmed payment, so withholding would punish a paying user for your
507
- outage. The queued transaction is re-validated on a later sync, and a rejection
508
- then revokes — which bounds the exposure this default accepts. Choose `'withhold'`
509
- if your threat model requires it.
510
-
511
- A hook that throws is treated as unavailable, never as an implicit approval.
512
-
513
- This package calls **no endpoint** other than the platform's billing service. All
514
- other traffic is your own code, through this hook.
515
-
516
- ---
517
-
518
- ## Localizable copy
519
-
520
- The package exports message keys with English defaults and takes no i18n runtime
521
- dependency.
522
-
523
- ```ts
524
- import { DEFAULT_USER_MESSAGES, USER_MESSAGE_KEYS, isUserMessageKey } from '@chipmobilesdk/rn-payment';
525
- ```
526
-
527
- User-actionable and developer copy are **separate maps**, so you cannot
528
- accidentally show "Payment catalog is invalid" to a customer. `isUserMessageKey()`
529
- lets you assert that at your own boundary.
530
-
531
- Message keys are public API; renaming one is a breaking change.
532
-
533
- ---
534
-
535
- ## Events and errors
536
-
537
- 19 event types, each carrying a timestamp and — within a purchase cycle — one
538
- `correlationId`. The bar is that the event stream **alone** is enough to
539
- reconstruct a transaction's lifecycle, so "the user says they paid but has no
540
- entitlement" can be investigated from logs without a reproduction.
541
-
542
- Ordering for a successful purchase:
543
-
544
- ```text
545
- purchase_started → purchase_succeeded → [validation_decided] → entitlement_granted → transaction_finalized
546
- ```
547
-
548
- Exactly-once guarantees: one grant per transition (not per source), one revocation
549
- per transition, no event for a renewal that changed nothing, one finalize per
550
- transaction however many times it is retried, and **nothing at all after
551
- cleanup**.
552
-
553
- **Never present in an event payload**: store objects, raw receipts, verification
554
- payloads, backend tokens, payment details, the obfuscated account id, or the cache
555
- scope token.
556
-
557
- A listener that throws cannot corrupt the state machine or stop other listeners.
558
- The failure goes to your `logger`, not back into the event stream.
559
-
560
- Errors carry a stable `code`, a log-safe `message`, the `operation`, a `retryable`
561
- flag, and a redacted `cause`. Error codes are public API.
562
-
563
- ---
564
-
565
- ## Wiring an entitlement to another system
566
-
567
- **This is your job, and that is the point.** This package references no
568
- advertising package, and no advertising package references it.
569
-
570
- ```ts
571
- // Your app. The whole integration.
572
- function enablementForEntitlement(status) {
573
- if (status === 'entitled') return 'disabled'; // they paid not to see ads
574
- if (status === 'not_entitled') return 'enabled'; // confirmed unpaid
575
- return 'undecided'; // not yet known — hold
576
- }
577
-
578
- engine.subscribeEntitlements(map => {
579
- ads.setEnabled(enablementForEntitlement(map.no_ads.status));
580
- });
581
- ```
582
-
583
- The middle line is why `unknown` exists. Enabling while waiting flashes an ad at
584
- someone who paid not to see one; disabling while waiting costs revenue on every
585
- other user's first screen. Holding is a deliberate choice made with real
586
- information.
587
-
588
- A worked version is in the demo app at `src/payment/adsWiring.ts`.
589
-
590
- ---
591
-
592
- ## Testing
593
-
594
- ```ts
595
- import { createFakeStore, makePurchase } from '@chipmobilesdk/rn-payment/testing';
596
-
597
- const store = createFakeStore();
598
- const engine = createEngine(config, { provider: store });
599
-
600
- store.scriptPurchaseSuccess({ storeProductId: 'com.yourapp.remove_ads' });
601
- await engine.purchase('remove_ads');
602
- ```
603
-
604
- The fake store drives every purchase outcome, both callback orderings, pending
605
- settlement across sessions, connection loss and recovery, refunds, failing
606
- acknowledgement, and an app-kill simulation (drop memory, keep the snapshot,
607
- restart). No device, no network, no store account, no real money.
608
-
609
- `./testing` is a separate entrypoint so test doubles cannot reach a production
610
- bundle.
611
-
612
- ### Licence testers, for device verification
613
-
614
- 1. Play Console → **Setup → Licence testing**, add tester accounts.
615
- 2. Publish to at least one testing track. **In-app products do not work at all
616
- for a build that has never been uploaded to Play.**
617
- 3. Sign in on the device with a licence tester account.
618
- 4. Use a shortened subscription renewal cycle to observe the lifecycle.
619
-
620
- Never use a production transaction as a verification step.
621
-
622
- ---
623
-
624
- ## What is stored on the device
625
-
626
- Exhaustively, for your Data safety declaration:
627
-
628
- | Stored | Why |
629
- |---|---|
630
- | Entitlement keys and their states | So entitlements are readable offline |
631
- | Granting product keys | So you can show the current plan |
632
- | Subscription expiry and renewal status | So lifecycle is readable offline |
633
- | Unfinished-transaction records (transaction id, product key, token, attempt count) | So a paid purchase is never lost to an app kill |
634
- | Last successful sync time | So data age is observable |
635
- | Schema version | So the record can be migrated |
636
- | Your `scopeToken`, if you supply one | Solely to partition the record |
637
-
638
- **Not stored**: payment details, card data, billing addresses, user identity, raw
639
- receipts, verification payloads, backend tokens.
640
-
641
- Stored in MMKV under the instance id `chipmobilesdk.payment`. `clearLocalData()`
642
- erases all of it. On Android, declare backup exclusion in your app's
643
- `data_extraction_rules.xml` if you do not want the record restored to a new
644
- device — the snapshot is rebuilt from the store anyway.
645
-
646
- ---
647
-
648
- ## Trust boundary
649
-
650
- **Client-side entitlement data can be tampered with on a compromised device.** A
651
- rooted user can edit local storage. This package does not claim otherwise, and it
652
- does not pretend encryption would help: anything the device can decrypt, its owner
653
- can decrypt.
654
-
655
- **Only server-side validation is a real defence.** If your product can be
656
- meaningfully abused, use the validation hook with a backend that verifies receipts
657
- against the Play Developer API.
658
-
659
- ---
660
-
661
- ## Play Console checklist
662
-
663
- - [ ] Developer account **payments profile** completed — without it nothing is
664
- sellable.
665
- - [ ] Each in-app product created with the exact id from your catalog, and
666
- **activated**.
667
- - [ ] Subscriptions have a base plan, and an offer if you want a trial.
668
- - [ ] Prices set per country; tax settings configured.
669
- - [ ] Licence testers added.
670
- - [ ] App published to at least one testing track.
671
- - [ ] Shortened renewal cycles enabled for lifecycle testing.
672
- - [ ] Review notes describe how a reviewer reaches purchasable content.
673
- - [ ] A demo account supplied if your paywall sits behind a login.
674
- - [ ] Real-time developer notifications configured, if you have a backend.
675
-
676
- ---
677
-
678
- ## Play policy checklist
679
-
680
- Your obligations as the app owner — this package cannot discharge them for you:
681
-
682
- - [ ] In-app digital goods use Play's billing system. This package provides **no
683
- route** to alternative billing, user-choice billing, or an external purchase
684
- call to action.
685
- - [ ] Price, billing period, renewal terms, trial conditions, and how to cancel
686
- are disclosed **before** payment, where the user can see them.
687
- - [ ] A route to the store's subscription management screen is reachable in-app.
688
- - [ ] A "Restore purchases" control is reachable in-app.
689
- - [ ] Data safety declaration reflects the stored-data list above plus the billing
690
- library's own behaviour.
691
- - [ ] Privacy policy and terms of sale are valid and current.
692
- - [ ] Refund requests handled per platform policy.
693
- - [ ] Auto-renewal legal requirements met for each market you sell in.
694
-
695
- ---
696
-
697
- ## Production release checklist
698
-
699
- - [ ] `environment: 'production'` and `isProductionBuild: true` only in release
700
- builds.
701
- - [ ] `npm pack --dry-run` output contains no store product id, credential, or
702
- secret.
703
- - [ ] The Android adapter verified against the installed `react-native-iap`
704
- version.
705
- - [ ] All three product types purchased on a device with a licence tester.
706
- - [ ] A pending purchase settled via a slow test payment method.
707
- - [ ] Restore verified after uninstall and reinstall.
708
- - [ ] Subscription lifecycle observed on shortened cycles.
709
- - [ ] Entitlements readable in airplane mode.
710
- - [ ] App starts and runs on a device without Play services.
711
- - [ ] Store-side records show **zero** production transactions from
712
- development/test builds. Retain as release evidence.
713
-
714
- ---
715
-
716
- ## Known limitations
717
-
718
- 1. **Grace period and account hold are not separable on Android** client-side. See
719
- [Subscriptions](#subscriptions). The defaults work with no backend; a
720
- non-default policy needs server data.
721
- 2. **iOS is contract-only.** `createStoreProvider()` returns an `unavailable` stub
722
- on iOS; the host app still starts and runs.
723
- 3. **The Android adapter is unverified** against an installed binding. See
724
- [Compatibility](#compatibility).
725
- 4. **Price-change confirmation is unsupported on Android.** Play notifies the user
726
- itself and offers no client-side flow, so `confirmPriceChange` reports
727
- `unsupported` rather than faking a confirmation.
728
- 5. **Client-side entitlement is forgeable.** See
729
- [Trust boundary](#trust-boundary).
730
- 6. **Cross-platform entitlement sync is out of scope.** One user with an Android
731
- and an iOS device is a backend problem, not a client one.
732
-
733
- ---
734
-
735
- ## Adding iOS later
736
-
737
- Implement `ProviderPort` for StoreKit. Nothing else changes.
738
-
739
- | Play concept | Port concept | StoreKit |
740
- |---|---|---|
741
- | `acknowledge` | `acknowledge` | `Transaction.finish()` |
742
- | `consume` | `consume` | `Transaction.finish()` on a consumable |
743
- | base plan / offer | `ProviderOffer` | subscription group / offer |
744
- | pending purchase | pending `ProviderPurchase` | parental-approval pending transaction |
745
- | — | `supportsExternalTransactions: true` | externally-initiated purchase |
746
-
747
- Consumers add `storeProductIds.ios` to their catalog entries. No product key,
748
- entitlement key, event name, error code, state value, or message key changes —
749
- which is the entire point of the boundary.
750
-
751
- ---
752
-
753
- ## Versioning
754
-
755
- Semantic versioning. **Breaking** means any of these changes: the public API
756
- shape, a product key or entitlement key contract, an error code, an event name, a
757
- state value, a message key, or the local storage format without a migration.
758
-
759
- Local storage carries a schema version and a forward migration chain. A record
760
- that cannot be migrated is discarded and rebuilt from the store — safe, because
761
- discarding yields `unknown`, never a false `not_entitled`.
1
+ # @chipmobilesdk/rn-payment
2
+
3
+ Reusable in-app payment and entitlement capability for ChipMobileSdk-family React
4
+ Native apps. Android only in 0.2.0, via Google Play Billing, behind a contract
5
+ that is neutral with respect to platform and store.
6
+
7
+ **The package owns capability. Your app owns policy.** The package gives you a
8
+ safe, consistent way to sell, finalize, restore, and report entitlements. What you
9
+ sell, at what price, on which screen, at which moment, what each entitlement
10
+ unlocks, and how generous your grace policy is — all yours. There is no product
11
+ identifier, no screen name, and no business rule in this package.
12
+
13
+ ---
14
+
15
+ ## Contents
16
+
17
+ - [The one idea that matters](#the-one-idea-that-matters)
18
+ - [Install](#install)
19
+ - [Compatibility](#compatibility)
20
+ - [Quick start](#quick-start)
21
+ - [Catalog and entitlement mapping](#catalog-and-entitlement-mapping)
22
+ - [The entitlement model](#the-entitlement-model)
23
+ - [Purchase flow](#purchase-flow)
24
+ - [Finalization: the invariant you cannot opt out of](#finalization-the-invariant-you-cannot-opt-out-of)
25
+ - [Consumables](#consumables)
26
+ - [Subscriptions](#subscriptions)
27
+ - [Prices and offers](#prices-and-offers)
28
+ - [Sync, restore, and offline](#sync-restore-and-offline)
29
+ - [Environments and the production guard](#environments-and-the-production-guard)
30
+ - [Server-side validation](#server-side-validation)
31
+ - [Localizable copy](#localizable-copy)
32
+ - [Events and errors](#events-and-errors)
33
+ - [Wiring an entitlement to another system](#wiring-an-entitlement-to-another-system)
34
+ - [Testing](#testing)
35
+ - [What is stored on the device](#what-is-stored-on-the-device)
36
+ - [Trust boundary](#trust-boundary)
37
+ - [Play Console checklist](#play-console-checklist)
38
+ - [Play policy checklist](#play-policy-checklist)
39
+ - [Production release checklist](#production-release-checklist)
40
+ - [Known limitations](#known-limitations)
41
+ - [Adding iOS later](#adding-ios-later)
42
+ - [Versioning](#versioning)
43
+
44
+ ---
45
+
46
+ ## The one idea that matters
47
+
48
+ Your business logic asks about **entitlement keys** you chose — `no_ads`,
49
+ `pro_features` — and never about store product ids, product types, or which store
50
+ sold the thing.
51
+
52
+ ```ts
53
+ const { status } = useEntitlement('no_ads');
54
+ ```
55
+
56
+ `status` is `'unknown' | 'entitled' | 'not_entitled'`. The third state is why this
57
+ package exists: on a cold start we do not yet know whether this user paid, and
58
+ saying so is strictly better than guessing. If you collapse `unknown` into
59
+ `not_entitled`, a paying user watches ads flash before they vanish, or sees a
60
+ purchased feature locked for a second. Render a loading treatment for `unknown`.
61
+
62
+ ---
63
+
64
+ ## Install
65
+
66
+ ```bash
67
+ npm install @chipmobilesdk/rn-payment
68
+ ```
69
+
70
+ Peer dependencies you must install yourself:
71
+
72
+ ```bash
73
+ npm install react-native-iap react-native-mmkv react-native-nitro-modules
74
+ ```
75
+
76
+ - `react-native-iap` — the Play Billing binding. Reached only through this
77
+ package's Android adapter; you never import it.
78
+ - `react-native-mmkv` — synchronous local storage. Required: it is what lets
79
+ entitlement reads be synchronous on a render path. Without it entitlements do
80
+ not survive a cold start and read `unknown` until the first sync, which is safe
81
+ but degraded.
82
+ - `react-native-nitro-modules` — transitive requirement of `react-native-iap`.
83
+
84
+ > **Android manifest.** `com.android.vending.BILLING` is declared by the billing
85
+ > library's own `AndroidManifest.xml` and merged into your app automatically. You
86
+ > do **not** add it yourself. No runtime permission is requested and no sensitive
87
+ > API is touched, so there is no permission prompt and no denial path to handle.
88
+ >
89
+ > **Verified state in this repository:** the app manifest at
90
+ > `android/app/src/main/AndroidManifest.xml` declares `INTERNET`, `CAMERA`, and
91
+ > notification permissions, and **no** `BILLING` entry — correct, because the
92
+ > billing library is not installed yet. Once it is, confirm the merged manifest
93
+ > contains exactly one `com.android.vending.BILLING` entry and no new runtime
94
+ > permission:
95
+ >
96
+ > ```bash
97
+ > # After a debug build, inspect the merge report:
98
+ > cat android/app/build/outputs/logs/manifest-merger-debug-report.txt | grep -i billing
99
+ > ```
100
+
101
+ ---
102
+
103
+ ## Compatibility
104
+
105
+ Verified against this repository's toolchain, not assumed:
106
+
107
+ | Item | Supported |
108
+ |---|---|
109
+ | React | >= 19 (verified 19.2.3) |
110
+ | React Native | >= 0.85 (verified 0.85.3), New Architecture only |
111
+ | TypeScript | >= 5.8 |
112
+ | Node | >= 22.11.0 |
113
+ | Android `minSdk` / `compileSdk` / `targetSdk` | 24 / 36 / 36 |
114
+ | Kotlin | 2.1.20 |
115
+ | `react-native-iap` | >= 16.6.1 < 17 |
116
+ | `react-native-mmkv` | >= 4.0.0 |
117
+ | `react-native-nitro-modules` | >= 0.36.5 |
118
+
119
+ **Why the `react-native-iap` ceiling is the next major.** Both the Nitro peer
120
+ requirement and the bundled Play Billing version move on majors, so a major bump
121
+ needs re-verification rather than silent adoption.
122
+
123
+ > ⚠️ **The Android adapter is written but not yet verified on a device.** Its call
124
+ > shapes follow `react-native-iap` 16.x as documented, and the surface it depends on
125
+ > is declared explicitly in a local `IapModule` interface so a mismatch fails at
126
+ > typecheck rather than at runtime on a user's device. Verify it on a device before
127
+ > shipping.
128
+ >
129
+ > One part *has* been checked against an installed binding (16.7.3): how a purchase's
130
+ > state is read. That check found a defect in `0.1.0` — the binding sends the state as
131
+ > a string, the adapter expected a number, and a pending purchase was granted. Fixed
132
+ > in `0.2.0`; see the changelog.
133
+
134
+ ---
135
+
136
+ ## Quick start
137
+
138
+ ```tsx
139
+ import {
140
+ PaymentProvider,
141
+ createStoreProvider,
142
+ useEntitlement,
143
+ usePurchase,
144
+ } from '@chipmobilesdk/rn-payment';
145
+
146
+ const config = {
147
+ catalog: [
148
+ {
149
+ productKey: 'remove_ads',
150
+ productType: 'one_time_permanent',
151
+ storeProductIds: { android: 'com.yourapp.remove_ads' },
152
+ grants: ['no_ads'],
153
+ },
154
+ ],
155
+ entitlements: ['no_ads'],
156
+ environment: 'production',
157
+ isProductionBuild: true,
158
+ };
159
+
160
+ export function App() {
161
+ return (
162
+ <PaymentProvider config={config} deps={{ provider: createStoreProvider() }}>
163
+ <YourApp />
164
+ </PaymentProvider>
165
+ );
166
+ }
167
+
168
+ function AdSlot() {
169
+ const noAds = useEntitlement('no_ads');
170
+
171
+ // Hold while unknown. Do not guess.
172
+ if (noAds.status === 'unknown') return null;
173
+ if (noAds.status === 'entitled') return null;
174
+ return <YourBanner />;
175
+ }
176
+ ```
177
+
178
+ `createStoreProvider()` picks the platform adapter. On a platform with no adapter
179
+ it returns a stub that reports `unavailable` instead of throwing, so your app
180
+ still starts and runs — it only loses the ability to sell.
181
+
182
+ **Call `handleForeground()` when your app returns to the foreground.** It is left
183
+ to you rather than subscribed internally, because this package must not decide
184
+ what "foreground" means for an app that may be backgrounded by its own modal, a
185
+ biometric prompt, or the store's own purchase sheet. It is rate-limited
186
+ regardless, so calling it liberally is safe.
187
+
188
+ ---
189
+
190
+ ## Catalog and entitlement mapping
191
+
192
+ The catalog is the whole of your product configuration, and the only place a store
193
+ product id appears.
194
+
195
+ ```ts
196
+ catalog: [
197
+ {
198
+ productKey: 'premium_monthly', // yours
199
+ productType: 'subscription',
200
+ storeProductIds: {
201
+ android: 'com.yourapp.premium.monthly', // Play Console
202
+ ios: 'com.yourapp.premium.monthly', // optional, additive
203
+ },
204
+ grants: ['no_ads', 'pro_features'], // yours
205
+ },
206
+ ]
207
+ ```
208
+
209
+ **The mapping is many-to-many.** Several products may grant one entitlement — a
210
+ monthly plan, a yearly plan, and a lifetime unlock all granting `no_ads` — and one
211
+ product may grant several. An entitlement stays valid while **any** granting
212
+ product remains valid, so losing one source does not revoke it.
213
+
214
+ Validation runs at configure time, before anything connects, and names the
215
+ offending entry. It catches duplicate product keys, a missing store id for the
216
+ current platform, an invalid product type, a product granting nothing, two
217
+ products sharing a store id, an entitlement no product grants, and an entitlement
218
+ granted but never declared. All problems are reported in one pass.
219
+
220
+ ### Creating the products in Play Console
221
+
222
+ 1. Create each product with the exact id you put in `storeProductIds.android`.
223
+ 2. One-time products go under **In-app products**; consumables are also in-app
224
+ products — the *package* decides consumable behaviour via `productType`, not
225
+ Play.
226
+ 3. Subscriptions need a **base plan**, and an **offer** if you want a trial or
227
+ introductory price.
228
+ 4. Activate each product. An inactive product is reported as
229
+ `not_available_from_store`, distinctly from a network error.
230
+
231
+ ---
232
+
233
+ ## The entitlement model
234
+
235
+ ```ts
236
+ const state = engine.getEntitlement('no_ads');
237
+ // { key, status, sources, subscription?, lastSyncedAt?, isStale }
238
+ ```
239
+
240
+ | Status | Meaning | What to render |
241
+ |---|---|---|
242
+ | `unknown` | Not yet known, or the cached answer went stale | A loading or held treatment |
243
+ | `entitled` | At least one valid source grants it | The paid experience |
244
+ | `not_entitled` | Synced, fresh, and nothing grants it | The unpaid experience |
245
+
246
+ `unknown` is **re-entrant**, not just an initial state: it returns when cached
247
+ data passes its staleness ceiling. Handle it as a real state, not a startup
248
+ flicker.
249
+
250
+ `sources` names *your* product keys, so you can show "You're on the yearly plan"
251
+ without touching a transaction record.
252
+
253
+ Changes are pushed. You never poll.
254
+
255
+ ---
256
+
257
+ ## Purchase flow
258
+
259
+ ```ts
260
+ const result = await engine.purchase('premium_monthly', {
261
+ // Optional. Passed straight to the store for fraud prevention and never
262
+ // generated, inferred, or persisted by this package.
263
+ obfuscatedAccountId: yourOpaqueAccountId,
264
+ offerKey: selectedOffer.offerKey,
265
+ });
266
+ ```
267
+
268
+ `result.kind` is a discriminated union, and the distinctions matter:
269
+
270
+ | Kind | What it means | Suggested handling |
271
+ |---|---|---|
272
+ | `success` | Paid and granted | Show the unlocked state |
273
+ | `pending` | Slow payment method; **not granted** | Show a waiting notice, and keep showing it — see [A payment that stays pending](#a-payment-that-stays-pending) |
274
+ | `user_cancelled` | Normal. Not an error | Say nothing |
275
+ | `already_owned` | Store says they own it; a re-sync was triggered | Show the unlocked state |
276
+ | `ineligible` | Not eligible for this offer | Offer an alternative |
277
+ | `unknown_product` | Not in your catalog | Developer bug |
278
+ | `product_unavailable_in_region` | Not sold where they are | Explain |
279
+ | `disabled` | Your global flag is off | Hide the entry point |
280
+ | `not_ready` | Not initialized or connecting | Retry later |
281
+ | `purchase_in_progress` | Another flow is open | Ignore the second tap |
282
+ | `environment_blocked` | Non-production build refused a real charge | Developer-facing |
283
+ | `validation_rejected` | Your backend said no | Explain |
284
+ | `timeout` / `network_error` / `store_error` | Transient or store-side | Offer retry |
285
+
286
+ **Never report `user_cancelled` as a failure.** Showing an error banner to someone
287
+ who simply changed their mind is the most common paywall bug this union exists to
288
+ prevent.
289
+
290
+ The purchase surface is rendered by the store. This package neither controls,
291
+ customizes, nor can verify its accessibility — it only reports what happened.
292
+
293
+ ### A payment that stays pending
294
+
295
+ **New in `0.2.0`.** A slow payment method — cash at a shop, a bank transfer — can
296
+ stay pending for days, long past the session that started it. `purchase()`
297
+ returning `pending` tells you once; the **pending set** keeps telling you, in this
298
+ session and every later one, until the store says how it ended.
299
+
300
+ ```ts
301
+ const pending = engine.getPendingPurchases(); // synchronous, in memory
302
+ engine.subscribePendingPurchases(next => { /* ... */ }); // pushed, never polled
303
+
304
+ // In React, narrowed to what this screen sells:
305
+ const { status, purchases } = usePendingPurchases(['remove_ads']);
306
+
307
+ if (status === 'unknown') {
308
+ // Not known yet, or the snapshot is stale. Do not offer Buy on this.
309
+ } else if (purchases.length > 0) {
310
+ // Show "payment pending" — the `payment.state.pending` message key —
311
+ // and withhold Buy, so the user is not charged twice.
312
+ }
313
+ ```
314
+
315
+ Each entry is `{ productKey, since }`: one per product, `since` being when the
316
+ store reports the purchase was made. No transaction id, token, or store object.
317
+
318
+ **Read `status` before the list.** `unknown` follows the entitlement rule exactly:
319
+ never synced, or synced more than `staleAfterMs` ago. Under `unknown`, `purchases`
320
+ still holds what the last good sync saw, but an empty list there is **not**
321
+ "nothing pending", for the same reason an `unknown` entitlement is not
322
+ `not_entitled`.
323
+
324
+ **What ends it** — one `purchase_pending_resolved` event per pending transaction,
325
+ carrying `productKey` and an `outcome`:
326
+
327
+ | Outcome | What happened | What follows |
328
+ |---|---|---|
329
+ | `settled` | The money arrived | `entitlement_granted`, **after** this event, if the product confers one |
330
+ | `cancelled` | It ended without payment: declined, abandoned, or the store gave no reason | Nothing; Buy can come back |
331
+ | `expired` | The store reported the pending window ran out | Nothing; Buy can come back |
332
+
333
+ On Android you will only ever see `settled` and `cancelled`. Play does not say why
334
+ a pending purchase ended; it simply stops reporting it. So a declined payment and
335
+ an expired one both arrive as `cancelled`, and the package does not guess between
336
+ them from elapsed time. `expired` is there for a store that does report it.
337
+
338
+ The event fires at the sync, or the store push, that first sees the change: never
339
+ twice for one transaction, never for a sync that changed nothing, and never for
340
+ `clearLocalData()`, which discards records rather than ending them.
341
+
342
+ Pending still **never grants**, under any configuration. The pending set only
343
+ lets you see it.
344
+
345
+ ---
346
+
347
+ ## Finalization: the invariant you cannot opt out of
348
+
349
+ Google **auto-refunds and revokes** a purchase that is not acknowledged within
350
+ three days. So acknowledgement is not a step you can forget here: the package
351
+ owns it.
352
+
353
+ The ordering that makes it safe is: the intent to finalize is written to local
354
+ storage **before** the store call is attempted. A crash before the write leaves
355
+ the purchase still owned, so the next sync re-queues it. A crash after the write
356
+ leaves a queue record, so the next startup drains it. There is no window where a
357
+ paid transaction is forgotten.
358
+
359
+ Failed attempts retry under bounded backoff. Exhausting a session's budget is
360
+ **not** giving up — the record stays queued and the next launch tries again, which
361
+ is how the package stays inside the three-day window without hammering a store
362
+ that is currently refusing.
363
+
364
+ ```ts
365
+ // Observable rather than swallowed.
366
+ engine.getUnfinalized(); // [{ productKey, transactionId, attempts, purchasedAt }]
367
+ ```
368
+
369
+ Entitlement is granted **before** finalization completes, deliberately: a store
370
+ hiccup must never withhold something already paid for.
371
+
372
+ ---
373
+
374
+ ## Consumables
375
+
376
+ For a consumable, **you** decide when it counts as consumed, because only you know
377
+ whether the item was actually banked in your own data.
378
+
379
+ ```ts
380
+ const result = await engine.purchase('coin_pack_100');
381
+ if (result.kind === 'success') {
382
+ await yourDatabase.addCoins(100); // bank it first
383
+ await engine.consume(result.transaction); // then tell the store
384
+ }
385
+ ```
386
+
387
+ Until you call `consume`, the store will not allow a repurchase. Consuming twice,
388
+ or concurrently, grants the item exactly once. A consumable never becomes a
389
+ permanent entitlement.
390
+
391
+ ---
392
+
393
+ ## Subscriptions
394
+
395
+ ```ts
396
+ const state = engine.getEntitlement('pro_features');
397
+ state.subscription;
398
+ // { expiresAt?, willAutoRenew, isCancelledButValid, renewalState }
399
+ ```
400
+
401
+ A successful renewal changes nothing, so it emits **no** event — change signals
402
+ come from a diff, which makes that structural rather than a special case.
403
+
404
+ ### Grace and hold policy
405
+
406
+ ```ts
407
+ gracePolicy: 'grant', // default
408
+ holdPolicy: 'withhold', // default
409
+ ```
410
+
411
+ A **grace period** means the store is still retrying a payment method that failed
412
+ for reasons usually outside the user's control. Revoking immediately punishes a
413
+ paying customer for a billing hiccup. **Account hold** means the store has given
414
+ up retrying; the subscription is effectively unpaid.
415
+
416
+ > **Android cannot separate these client-side.** Play returns a grace-period
417
+ > subscription as active and stops returning a held one — making hold
418
+ > indistinguishable from expired, cancelled, or refunded. So `renewalState`
419
+ > reports `indeterminate` rather than inventing the distinction.
420
+ >
421
+ > **The defaults still work with no backend**, because Play's own behaviour
422
+ > matches them: a grace-period purchase keeps being returned (so the entitlement
423
+ > holds) and a held one stops being returned (so it lapses at the next sync). Only
424
+ > a *non-default* policy needs server data, through the validation hook.
425
+
426
+ ### Plan changes
427
+
428
+ ```ts
429
+ await engine.changePlan('premium_monthly', 'premium_yearly', 'immediate_with_time_proration');
430
+ ```
431
+
432
+ | Mode | Effect |
433
+ |---|---|
434
+ | `immediate_with_time_proration` | Switch now, credit unused time. Usual upgrade. |
435
+ | `immediate_and_charge_full_price` | Switch now, charge full price, restart the period. |
436
+ | `immediate_and_charge_prorated_price` | Switch now, charge the difference. |
437
+ | `deferred` | Keep the current plan until it expires. Usual downgrade. |
438
+
439
+ A plan change goes through the normal purchase flow, so it inherits the
440
+ single-flight lock, the environment guard, validation, and the finalization queue.
441
+
442
+ ### Cancellation
443
+
444
+ ```ts
445
+ await engine.openSubscriptionManagement('premium_monthly');
446
+ ```
447
+
448
+ This opens the **store's** screen. Platforms forbid an app presenting its own
449
+ cancellation screen, so there is deliberately no alternative in this package.
450
+
451
+ ---
452
+
453
+ ## Prices and offers
454
+
455
+ ```ts
456
+ const { products, missing } = await engine.getProducts(['premium_monthly']);
457
+ ```
458
+
459
+ **Render `formattedPrice`. Never build a price string.** Only the store knows the
460
+ right currency symbol, its placement, the decimal separator, and the grouping for
461
+ the user's locale — `1.990 ₫` and `$19.90` are not the same shape. The
462
+ `priceAmountMicros` field exists for sorting and comparison only.
463
+
464
+ Offer eligibility is **the store's verdict**. Computing it locally would promise a
465
+ trial the store then refuses at checkout.
466
+
467
+ `missing` separates `not_in_catalog` (your configuration) from
468
+ `not_available_from_store` (unapproved, or not sold in this country), and neither
469
+ is conflated with a network error — so you can explain an absent row instead of
470
+ rendering a blank.
471
+
472
+ Results are cached in memory for 15 minutes by default, never persisted, and
473
+ `refreshProducts` bypasses the cache.
474
+
475
+ ---
476
+
477
+ ## Sync, restore, and offline
478
+
479
+ Sync runs automatically at initialization and when you call `handleForeground()`,
480
+ rate-limited to once a minute by default.
481
+
482
+ ```ts
483
+ const outcome = await engine.restore();
484
+ if (outcome.ok && outcome.restored) { /* purchases came back */ }
485
+ else if (outcome.ok) { /* nothing found — say that, not "success" */ }
486
+ ```
487
+
488
+ **"Nothing found" is a distinct success.** Telling a user their purchases were
489
+ restored when none existed is a support ticket.
490
+
491
+ Entitlements are readable offline from the last successful sync. Past the
492
+ staleness ceiling — **7 days** by default — they become `unknown`, never
493
+ `not_entitled`. Seven days covers a holiday without connectivity while bounding
494
+ how long a refunded entitlement can outlive its refund.
495
+
496
+ When the store stops reporting a transaction, the entitlement is revoked at the
497
+ next sync. The cache never resurrects it.
498
+
499
+ **Pending purchases follow the same rules.** They are part of the persisted
500
+ snapshot, so after an app kill — even an offline restart — `getPendingPurchases()`
501
+ still lists them, and past the staleness ceiling it reports `unknown`, never an
502
+ empty "nothing pending". The sync that finds a pending purchase settled, or gone,
503
+ emits `purchase_pending_resolved`. See
504
+ [A payment that stays pending](#a-payment-that-stays-pending).
505
+
506
+ ### Logout and account switching
507
+
508
+ ```ts
509
+ await engine.clearLocalData();
510
+ ```
511
+
512
+ If your app has its own accounts, also supply a `scopeToken`:
513
+
514
+ ```ts
515
+ scopeToken: opaqueHashOfYourUserId,
516
+ ```
517
+
518
+ Cached entitlements are partitioned by it, so a second user cannot inherit the
519
+ first user's entitlements even if you forget to call `clearLocalData`. The token is
520
+ a cache-partition key — never interpreted, transmitted, or attached to a
521
+ transaction — and is distinct from `obfuscatedAccountId`.
522
+
523
+ ---
524
+
525
+ ## Environments and the production guard
526
+
527
+ ```ts
528
+ environment: 'development' | 'test' | 'production',
529
+ isProductionBuild: boolean, // you supply this; only you know how you built
530
+ ```
531
+
532
+ Two layers:
533
+
534
+ 1. **Fail closed at construction.** A non-production build declaring `production`
535
+ throws `PAYMENT_PRODUCTION_CONFIG_IN_NON_PRODUCTION_BUILD` and never connects.
536
+ 2. **Runtime block.** Outside production, any chargeable purchase is refused,
537
+ recorded in `getStatus().blockedOperations`, and emitted as
538
+ `environment_blocked` — observable in state, so a test can assert it rather
539
+ than scraping logs.
540
+
541
+ > **Why this blocks where `@chipmobilesdk/rn-ads` substitutes.** The ads package
542
+ > swaps in Google's official *test ad units* when a non-production build supplies
543
+ > production ones, keeping the app working. Play has no equivalent test **product**
544
+ > id — testing uses licence-tester *accounts* against real product ids — so
545
+ > substitution is impossible here. Blocking with an observable refusal is the only
546
+ > honest option, and the asymmetry is deliberate rather than an inconsistency.
547
+
548
+ ---
549
+
550
+ ## Server-side validation
551
+
552
+ Optional. Without it the package is fully functional client-side.
553
+
554
+ ```ts
555
+ validateTransaction: async candidate => {
556
+ const verdict = await fetch('https://your.api/verify', {
557
+ method: 'POST',
558
+ body: JSON.stringify(candidate),
559
+ }).then(r => r.json());
560
+ return { approved: verdict.ok, reason: verdict.reason };
561
+ },
562
+ onValidationUnavailable: 'grant_and_queue', // default
563
+ ```
564
+
565
+ **The hook runs for newly completed purchases only.** Purchases seen during sync
566
+ or restore are not resubmitted — which is what keeps restore and foreground sync
567
+ working while your backend is down. Revocation still flows from the store.
568
+
569
+ **When the backend is unreachable, the default grants and queues.** The store has
570
+ already confirmed payment, so withholding would punish a paying user for your
571
+ outage. The queued transaction is re-validated on a later sync, and a rejection
572
+ then revokes — which bounds the exposure this default accepts. Choose `'withhold'`
573
+ if your threat model requires it.
574
+
575
+ A hook that throws is treated as unavailable, never as an implicit approval.
576
+
577
+ This package calls **no endpoint** other than the platform's billing service. All
578
+ other traffic is your own code, through this hook.
579
+
580
+ ---
581
+
582
+ ## Localizable copy
583
+
584
+ The package exports message keys with English defaults and takes no i18n runtime
585
+ dependency.
586
+
587
+ ```ts
588
+ import { DEFAULT_USER_MESSAGES, USER_MESSAGE_KEYS, isUserMessageKey } from '@chipmobilesdk/rn-payment';
589
+ ```
590
+
591
+ User-actionable and developer copy are **separate maps**, so you cannot
592
+ accidentally show "Payment catalog is invalid" to a customer. `isUserMessageKey()`
593
+ lets you assert that at your own boundary.
594
+
595
+ Message keys are public API; renaming one is a breaking change.
596
+
597
+ ---
598
+
599
+ ## Events and errors
600
+
601
+ 20 event types, each carrying a timestamp and — within a purchase cycle — one
602
+ `correlationId`. The bar is that the event stream **alone** is enough to
603
+ reconstruct a transaction's lifecycle, so "the user says they paid but has no
604
+ entitlement" can be investigated from logs without a reproduction.
605
+
606
+ Ordering for a successful purchase:
607
+
608
+ ```text
609
+ purchase_started → purchase_succeeded → [validation_decided] → entitlement_granted → transaction_finalized
610
+ ```
611
+
612
+ And for one that goes pending, possibly across sessions:
613
+
614
+ ```text
615
+ purchase_started → purchase_pending
616
+ … later …
617
+ purchase_pending_resolved (settled) → [validation_decided] → entitlement_granted → transaction_finalized
618
+ purchase_pending_resolved (cancelled | expired)
619
+ ```
620
+
621
+ Exactly-once guarantees: one grant per transition (not per source), one revocation
622
+ per transition, no event for a renewal that changed nothing, one finalize per
623
+ transaction however many times it is retried, one resolution per pending
624
+ transaction, and **nothing at all after cleanup**.
625
+
626
+ **Never present in an event payload**: store objects, raw receipts, verification
627
+ payloads, backend tokens, payment details, the obfuscated account id, or the cache
628
+ scope token.
629
+
630
+ A listener that throws cannot corrupt the state machine or stop other listeners.
631
+ The failure goes to your `logger`, not back into the event stream.
632
+
633
+ Errors carry a stable `code`, a log-safe `message`, the `operation`, a `retryable`
634
+ flag, and a redacted `cause`. Error codes are public API.
635
+
636
+ ---
637
+
638
+ ## Wiring an entitlement to another system
639
+
640
+ **This is your job, and that is the point.** This package references no
641
+ advertising package, and no advertising package references it.
642
+
643
+ ```ts
644
+ // Your app. The whole integration.
645
+ function enablementForEntitlement(status) {
646
+ if (status === 'entitled') return 'disabled'; // they paid not to see ads
647
+ if (status === 'not_entitled') return 'enabled'; // confirmed unpaid
648
+ return 'undecided'; // not yet known — hold
649
+ }
650
+
651
+ engine.subscribeEntitlements(map => {
652
+ ads.setEnabled(enablementForEntitlement(map.no_ads.status));
653
+ });
654
+ ```
655
+
656
+ The middle line is why `unknown` exists. Enabling while waiting flashes an ad at
657
+ someone who paid not to see one; disabling while waiting costs revenue on every
658
+ other user's first screen. Holding is a deliberate choice made with real
659
+ information.
660
+
661
+ A worked version is in the demo app at `src/payment/adsWiring.ts`.
662
+
663
+ ---
664
+
665
+ ## Testing
666
+
667
+ ```ts
668
+ import { createFakeStore, makePurchase } from '@chipmobilesdk/rn-payment/testing';
669
+
670
+ const store = createFakeStore();
671
+ const engine = createEngine(config, { provider: store });
672
+
673
+ store.scriptPurchaseSuccess({ storeProductId: 'com.yourapp.remove_ads' });
674
+ await engine.purchase('remove_ads');
675
+ ```
676
+
677
+ The fake store drives every purchase outcome, both callback orderings, pending
678
+ settlement across sessions, connection loss and recovery, refunds, failing
679
+ acknowledgement, and an app-kill simulation (drop memory, keep the snapshot,
680
+ restart). No device, no network, no store account, no real money.
681
+
682
+ Ending a pending purchase changes store-side state only, as the real store does,
683
+ so the engine sees it at its next sync — in this session or after a simulated
684
+ app kill:
685
+
686
+ ```ts
687
+ store.scriptPurchasePending({ transactionId: 'txn_1' });
688
+ await engine.purchase('remove_ads'); // pending
689
+
690
+ store.settlePending('txn_1'); // the money arrives → settled
691
+ store.cancelPending('txn_1'); // declined or abandoned → cancelled
692
+ store.expirePending('txn_1'); // the window ran out → expired
693
+ store.pushPurchaseUpdate(...); // deliver a settlement with no sync
694
+ ```
695
+
696
+ `./testing` is a separate entrypoint so test doubles cannot reach a production
697
+ bundle.
698
+
699
+ ### Licence testers, for device verification
700
+
701
+ 1. Play Console → **Setup → Licence testing**, add tester accounts.
702
+ 2. Publish to at least one testing track. **In-app products do not work at all
703
+ for a build that has never been uploaded to Play.**
704
+ 3. Sign in on the device with a licence tester account.
705
+ 4. Use a shortened subscription renewal cycle to observe the lifecycle.
706
+
707
+ Never use a production transaction as a verification step.
708
+
709
+ ---
710
+
711
+ ## What is stored on the device
712
+
713
+ Exhaustively, for your Data safety declaration:
714
+
715
+ | Stored | Why |
716
+ |---|---|
717
+ | Entitlement keys and their states | So entitlements are readable offline |
718
+ | Granting product keys | So you can show the current plan |
719
+ | Subscription expiry and renewal status | So lifecycle is readable offline |
720
+ | Unfinished-transaction records (transaction id, product key, token, attempt count) | So a paid purchase is never lost to an app kill |
721
+ | Pending-purchase records (the same fields, plus the time the store reports the purchase was made) | So a payment still in flight is visible after an app kill. Unchanged since `0.1.0`; `0.2.0` only reads them |
722
+ | Last successful sync time | So data age is observable |
723
+ | Schema version | So the record can be migrated |
724
+ | Your `scopeToken`, if you supply one | Solely to partition the record |
725
+
726
+ **Not stored**: payment details, card data, billing addresses, user identity, raw
727
+ receipts, verification payloads, backend tokens.
728
+
729
+ Stored in MMKV under the instance id `chipmobilesdk.payment`. `clearLocalData()`
730
+ erases all of it. On Android, declare backup exclusion in your app's
731
+ `data_extraction_rules.xml` if you do not want the record restored to a new
732
+ device — the snapshot is rebuilt from the store anyway.
733
+
734
+ ---
735
+
736
+ ## Trust boundary
737
+
738
+ **Client-side entitlement data can be tampered with on a compromised device.** A
739
+ rooted user can edit local storage. This package does not claim otherwise, and it
740
+ does not pretend encryption would help: anything the device can decrypt, its owner
741
+ can decrypt.
742
+
743
+ **Only server-side validation is a real defence.** If your product can be
744
+ meaningfully abused, use the validation hook with a backend that verifies receipts
745
+ against the Play Developer API.
746
+
747
+ ---
748
+
749
+ ## Play Console checklist
750
+
751
+ - [ ] Developer account **payments profile** completed — without it nothing is
752
+ sellable.
753
+ - [ ] Each in-app product created with the exact id from your catalog, and
754
+ **activated**.
755
+ - [ ] Subscriptions have a base plan, and an offer if you want a trial.
756
+ - [ ] Prices set per country; tax settings configured.
757
+ - [ ] Licence testers added.
758
+ - [ ] App published to at least one testing track.
759
+ - [ ] Shortened renewal cycles enabled for lifecycle testing.
760
+ - [ ] Review notes describe how a reviewer reaches purchasable content.
761
+ - [ ] A demo account supplied if your paywall sits behind a login.
762
+ - [ ] Real-time developer notifications configured, if you have a backend.
763
+
764
+ ---
765
+
766
+ ## Play policy checklist
767
+
768
+ Your obligations as the app owner — this package cannot discharge them for you:
769
+
770
+ - [ ] In-app digital goods use Play's billing system. This package provides **no
771
+ route** to alternative billing, user-choice billing, or an external purchase
772
+ call to action.
773
+ - [ ] Price, billing period, renewal terms, trial conditions, and how to cancel
774
+ are disclosed **before** payment, where the user can see them.
775
+ - [ ] A route to the store's subscription management screen is reachable in-app.
776
+ - [ ] A "Restore purchases" control is reachable in-app.
777
+ - [ ] Data safety declaration reflects the stored-data list above plus the billing
778
+ library's own behaviour.
779
+ - [ ] Privacy policy and terms of sale are valid and current.
780
+ - [ ] Refund requests handled per platform policy.
781
+ - [ ] Auto-renewal legal requirements met for each market you sell in.
782
+
783
+ ---
784
+
785
+ ## Production release checklist
786
+
787
+ - [ ] `environment: 'production'` and `isProductionBuild: true` only in release
788
+ builds.
789
+ - [ ] `npm pack --dry-run` output contains no store product id, credential, or
790
+ secret.
791
+ - [ ] The Android adapter verified against the installed `react-native-iap`
792
+ version.
793
+ - [ ] All three product types purchased on a device with a licence tester.
794
+ - [ ] A pending purchase settled via a slow test payment method.
795
+ - [ ] Restore verified after uninstall and reinstall.
796
+ - [ ] Subscription lifecycle observed on shortened cycles.
797
+ - [ ] Entitlements readable in airplane mode.
798
+ - [ ] App starts and runs on a device without Play services.
799
+ - [ ] Store-side records show **zero** production transactions from
800
+ development/test builds. Retain as release evidence.
801
+
802
+ ---
803
+
804
+ ## Known limitations
805
+
806
+ 1. **Grace period and account hold are not separable on Android** client-side. See
807
+ [Subscriptions](#subscriptions). The defaults work with no backend; a
808
+ non-default policy needs server data.
809
+ 2. **iOS is contract-only.** `createStoreProvider()` returns an `unavailable` stub
810
+ on iOS; the host app still starts and runs.
811
+ 3. **The Android adapter is unverified on a device.** Only its purchase-state
812
+ mapping has been checked against an installed binding. See
813
+ [Compatibility](#compatibility).
814
+ 4. **Price-change confirmation is unsupported on Android.** Play notifies the user
815
+ itself and offers no client-side flow, so `confirmPriceChange` reports
816
+ `unsupported` rather than faking a confirmation.
817
+ 5. **Client-side entitlement is forgeable.** See
818
+ [Trust boundary](#trust-boundary).
819
+ 6. **Cross-platform entitlement sync is out of scope.** One user with an Android
820
+ and an iOS device is a backend problem, not a client one.
821
+ 7. **On Android, an unpaid pending purchase always ends as `cancelled`.** Play does
822
+ not report whether it was declined or expired, and the package will not guess
823
+ from elapsed time. See [A payment that stays pending](#a-payment-that-stays-pending).
824
+
825
+ ---
826
+
827
+ ## Adding iOS later
828
+
829
+ Implement `ProviderPort` for StoreKit. Nothing else changes.
830
+
831
+ | Play concept | Port concept | StoreKit |
832
+ |---|---|---|
833
+ | `acknowledge` | `acknowledge` | `Transaction.finish()` |
834
+ | `consume` | `consume` | `Transaction.finish()` on a consumable |
835
+ | base plan / offer | `ProviderOffer` | subscription group / offer |
836
+ | pending purchase | pending `ProviderPurchase` | parental-approval pending transaction |
837
+ | — (not reported) | `pendingEndReason` (optional) | why a pending transaction ended, if StoreKit says |
838
+ | — | `supportsExternalTransactions: true` | externally-initiated purchase |
839
+
840
+ Consumers add `storeProductIds.ios` to their catalog entries. No product key,
841
+ entitlement key, event name, error code, state value, or message key changes —
842
+ which is the entire point of the boundary.
843
+
844
+ ---
845
+
846
+ ## Versioning
847
+
848
+ Semantic versioning. **Breaking** means any of these changes: the public API
849
+ shape, a product key or entitlement key contract, an error code, an event name, a
850
+ state value, a message key, or the local storage format without a migration.
851
+
852
+ Local storage carries a schema version and a forward migration chain. A record
853
+ that cannot be migrated is discarded and rebuilt from the store — safe, because
854
+ discarding yields `unknown`, never a false `not_entitled`.
855
+
856
+ ### Migrating `0.1.0` to `0.2.0`
857
+
858
+ **One union gained a member**, so an exhaustive `switch` over it needs the new
859
+ case or a `default`:
860
+
861
+ | Union | New member |
862
+ |---|---|
863
+ | `PaymentEventType` / `PaymentEvent` | `'purchase_pending_resolved'`, carrying `{ productKey, outcome }` |
864
+
865
+ Everything else is additive, and `0.1.0` call sites compile and behave unchanged:
866
+
867
+ | Addition | Kind |
868
+ |---|---|
869
+ | `PaymentEngine.getPendingPurchases()`, `subscribePendingPurchases()` | new methods |
870
+ | `usePendingPurchases(productKeys?)` | new hook |
871
+ | `PendingPurchase`, `PendingPurchasesState`, `PendingResolution` | new types |
872
+ | Fake store (`./testing`): `settlePending`, `cancelPending`, `expirePending` | new methods |
873
+
874
+ A hand-written `PaymentEngine` implementation, typically a test mock, needs the two
875
+ new methods.
876
+
877
+ **No storage migration.** The schema stays at version 1: pending records were
878
+ already persisted by `0.1.0`, and `0.2.0` only reads them. A device upgrading with
879
+ a payment in flight sees it in the pending set at first launch.
880
+
881
+ **Replacing an app-side workaround.** If you kept your own "pending since" marker
882
+ because `0.1.0` could not show a pending payment in a later session, drop it for
883
+ `usePendingPurchases`. The marker had to guess when to give up, so a declined
884
+ payment kept showing "pending" until it expired. The pending set clears at the
885
+ sync that sees the payment end.