@chipmobilesdk/rn-payment 0.0.0-stage → 0.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/README.md CHANGED
@@ -1,3 +1,761 @@
1
- # Temporary Holding Version
1
+ # @chipmobilesdk/rn-payment
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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`.