@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/CHANGELOG.md +113 -1
- package/README.md +885 -761
- package/package.json +54 -54
- package/skills/sdk-rn-payment/SKILL.md +33 -6
- package/src/adapter/createStoreProvider.ts +117 -117
- package/src/adapter/iap-adapter.ts +547 -516
- package/src/adapter/port.ts +12 -0
- package/src/config/types.ts +40 -0
- package/src/diagnostics/events.ts +9 -0
- package/src/engine/engine.ts +951 -854
- package/src/engine/finalize.ts +298 -298
- package/src/engine/pending.ts +118 -0
- package/src/engine/products.ts +259 -259
- package/src/engine/purchase.ts +526 -495
- package/src/engine/subscriptions.ts +157 -157
- package/src/entitlements/aggregate.ts +21 -4
- package/src/index.ts +4 -0
- package/src/persistence/mmkv-store.ts +16 -8
- package/src/react/hooks.ts +274 -238
- package/src/testing/fakeStore.ts +33 -0
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.
|
|
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
|
|
124
|
-
>
|
|
125
|
-
>
|
|
126
|
-
>
|
|
127
|
-
>
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
|
271
|
-
|
|
272
|
-
| `
|
|
273
|
-
| `
|
|
274
|
-
| `
|
|
275
|
-
| `
|
|
276
|
-
| `
|
|
277
|
-
| `
|
|
278
|
-
| `
|
|
279
|
-
| `
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
//
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
`
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
###
|
|
443
|
-
|
|
444
|
-
```ts
|
|
445
|
-
await engine.
|
|
446
|
-
```
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
`
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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.
|