@chipmobilesdk/rn-payment 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,66 @@
1
1
  # @chipmobilesdk/rn-payment
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - The engine is split by responsibility, and its internals are off the public type. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
8
+
9
+ - `PaymentEngine` no longer has `resolvedConfig`, `nextCorrelationId`, `emitter`,
10
+ `snapshot`, `commitSnapshot`, `mayCreateRealTransaction` or `recordBlockedOperation`.
11
+ They were documented as internal, but any consumer could call `commitSnapshot` and
12
+ grant itself an entitlement the store never sold, and a hand-written engine double had
13
+ to implement all of them.
14
+ - `entitlement_revoked` now carries the real reason: `validation_rejected` when your
15
+ backend said no, `consumed` for a consumed item, `expired` for a lapsed subscription,
16
+ and `revoked_by_store` only when the store stopped reporting the purchase. Before, every
17
+ revocation said `revoked_by_store`.
18
+ - `clearLocalData()` also drops cached product prices.
19
+ - A `not_ready` purchase result reports the engine's actual status instead of always
20
+ `connecting`. `usePurchase().purchase` takes a `ProductKey`, as it always accepted.
21
+ - Local storage moves to schema version 2: the never-written `entitlements` map and
22
+ `finalized` purchase state are gone. A version 1 record is migrated in place on first
23
+ load, so nothing needs to be done.
24
+ - Internally, `engine.ts` is 346 lines (was 921): state, connection and sync are their
25
+ own modules, and sync and the purchase flow build purchase records with one function.
26
+ The unused `runWithRetry`, `backoffSchedule`, `sourcesFor` and `entitlementsCoveredBy`
27
+ helpers are gone.
28
+
29
+ - The provider keeps one engine, stored records are checked one by one, and product
30
+ queries run in parallel. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
31
+
32
+ - `PaymentProvider` builds its engine once and keeps it across renders. Inline
33
+ `config` and `deps` objects no longer reconnect to the store on every render of your
34
+ root. A new engine is built only when the new `engineKey` prop changes (default:
35
+ `config.scopeToken`). It now works under React StrictMode, where it used to be left
36
+ holding a cleaned-up engine that never became ready. Pass `engine` to share an engine
37
+ you created and own; the provider then neither initializes nor cleans it up.
38
+ - `initialize()` on a cleaned-up engine reports `PAYMENT_NOT_CONFIGURED` even if the
39
+ engine had been ready, instead of answering `ok`.
40
+ - Every stored purchase record is checked field by field when read. A damaged one is
41
+ dropped and comes back with the next sync, rather than being trusted or taking the
42
+ rest down with it. Only a `purchased` record grants an entitlement; a state this
43
+ version does not know never does.
44
+ - A failed write to local storage is reported as an `internal_error` with
45
+ `PAYMENT_STORAGE_UNAVAILABLE` and operation `persist`, instead of being swallowed.
46
+ A custom `SnapshotStore.save` may return `false` to report one.
47
+ - One-time and subscription products are queried in parallel, identical requests in
48
+ flight share one store call, and `useProducts` never lets an older answer overwrite a
49
+ newer one. `status.blockedOperations` keeps the 20 most recent.
50
+
51
+ - `allowStoreTestPurchases` lets a debug build buy through the real store for Play
52
+ license testers. It is accepted only with `environment: 'test'`; any other environment
53
+ rejects it at startup with `PAYMENT_INVALID_CONFIG`. Without it, non-production
54
+ purchases are still refused as `environment_blocked`, as before. The README no longer
55
+ says the billing library is uninstalled or the adapter unverified, and describes what
56
+ adding iOS actually involves. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
57
+
58
+ ### Patch Changes
59
+
60
+ - Internal: no module in these packages is over 500 lines any more. The camera controller's session I/O moved to `sessionRuntime` and its contract to `contract.ts`; the Play adapter's shape translation to `iapShapes`; the purchase flow's failure mapping and its receipt-validation hook to modules of their own; the AI client's quota request to `quotaRequest`; the in-memory adapter's attachments and the fake ads engine's clock likewise. Behaviour is unchanged — the splits are by responsibility, and every package's suite passes as it stood. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
61
+
62
+ The repository also measures what it promised: `npm run perf` prints the median of nine runs of the deterministic hot paths against written budgets (recurrence expansion, theme construction, translation lookup, chart geometry, filter compilation), the demo app has a Performance screen reporting startup phases, time to interactive and the same benchmarks on Hermes, and `npm run check:file-size` keeps the limit from drifting. The budgets and the measured figures are recorded in `docs/standards.md` §VI.
63
+
3
64
  ## 0.5.0
4
65
 
5
66
  ### Minor Changes
package/README.md CHANGED
@@ -91,12 +91,9 @@ npm install react-native-iap react-native-mmkv react-native-nitro-modules
91
91
  > do **not** add it yourself. No runtime permission is requested and no sensitive
92
92
  > API is touched, so there is no permission prompt and no denial path to handle.
93
93
  >
94
- > **Verified state in this repository:** the app manifest at
95
- > `android/app/src/main/AndroidManifest.xml` declares `INTERNET`, `CAMERA`, and
96
- > notification permissions, and **no** `BILLING` entry — correct, because the
97
- > billing library is not installed yet. Once it is, confirm the merged manifest
98
- > contains exactly one `com.android.vending.BILLING` entry and no new runtime
99
- > permission:
94
+ > **Check your merged manifest** once `react-native-iap` is installed: it should
95
+ > contain exactly one `com.android.vending.BILLING` entry, added by the billing
96
+ > library, and no new runtime permission:
100
97
  >
101
98
  > ```bash
102
99
  > # After a debug build, inspect the merge report:
@@ -164,9 +161,13 @@ const config = {
164
161
  isProductionBuild: true,
165
162
  };
166
163
 
164
+ // Created once, outside the component: the store adapter holds the billing
165
+ // connection.
166
+ const deps = { provider: createStoreProvider() };
167
+
167
168
  export function App() {
168
169
  return (
169
- <PaymentProvider config={config} deps={{ provider: createStoreProvider() }}>
170
+ <PaymentProvider config={config} deps={deps}>
170
171
  <YourApp />
171
172
  </PaymentProvider>
172
173
  );
@@ -182,6 +183,13 @@ function AdSlot() {
182
183
  }
183
184
  ```
184
185
 
186
+ The provider builds its engine once and keeps it across renders; `config` and `deps`
187
+ are read when it is built. It builds a new one when `engineKey` changes (by default
188
+ `config.scopeToken`, so switching accounts gets a fresh engine), and it survives React
189
+ StrictMode in development. To own the engine yourself — create it at startup, share it
190
+ with code outside React — pass it instead: `<PaymentProvider engine={engine}>`. The
191
+ provider then neither initializes nor cleans it up.
192
+
185
193
  `createStoreProvider()` picks the platform adapter. On a platform with no adapter
186
194
  it returns a stub that reports `unavailable` instead of throwing, so your app
187
195
  still starts and runs — it only loses the ability to sell.
@@ -551,6 +559,20 @@ Two layers:
551
559
  recorded in `getStatus().blockedOperations`, and emitted as
552
560
  `environment_blocked` — observable in state, so a test can assert it rather
553
561
  than scraping logs.
562
+ 3. **License testing, opted in.** `environment: 'test'` with
563
+ `allowStoreTestPurchases: true` lets a debug build buy through the real store,
564
+ so a Play license tester can check a purchase without a release build from a
565
+ track. Any other environment rejects the option at construction. License
566
+ testers are not charged; anyone else signed in on the device would be.
567
+
568
+ ```ts
569
+ const config = {
570
+ // ...
571
+ environment: 'test',
572
+ isProductionBuild: false,
573
+ allowStoreTestPurchases: true, // license testers only
574
+ };
575
+ ```
554
576
 
555
577
  > **Why this blocks where `@chipmobilesdk/rn-ads` substitutes.** The ads package
556
578
  > swaps in Google's official *test ad units* when a non-production build supplies
@@ -751,10 +773,8 @@ Exhaustively, for your Data safety declaration:
751
773
 
752
774
  | Stored | Why |
753
775
  |---|---|
754
- | Entitlement keys and their states | So entitlements are readable offline |
755
- | Granting product keys | So you can show the current plan |
756
- | Subscription expiry and renewal status | So lifecycle is readable offline |
757
- | Unfinished-transaction records (transaction id, product key, token, attempt count) | So a paid purchase is never lost to an app kill |
776
+ | Purchase records (transaction id, product key, product type, token, purchase time, store state, finalize state and attempt count, backend validation state) | So entitlements are readable offline and a paid purchase is never lost to an app kill. Entitlements themselves are derived from these, never stored |
777
+ | Subscription expiry and renewal status, on the purchase record | So lifecycle is readable offline |
758
778
  | 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 |
759
779
  | Last successful sync time | So data age is observable |
760
780
  | Schema version | So the record can be migrated |
@@ -845,9 +865,11 @@ Your obligations as the app owner — this package cannot discharge them for you
845
865
  non-default policy needs server data.
846
866
  2. **iOS is contract-only.** `createStoreProvider()` returns an `unavailable` stub
847
867
  on iOS; the host app still starts and runs.
848
- 3. **The Android adapter is unverified on a device.** Only its purchase-state
849
- mapping has been checked against an installed binding. See
850
- [Compatibility](#compatibility).
868
+ 3. **Debug builds do not reach the store unless you opt in.** Outside production a
869
+ purchase reports `environment_blocked`. For a license tester on a debug build,
870
+ set `environment: 'test'` and `allowStoreTestPurchases: true`; only license
871
+ testers are not charged. See [Compatibility](#compatibility) for the devices and
872
+ binding the Android adapter was verified with.
851
873
  4. **Price-change confirmation is unsupported on Android.** Play notifies the user
852
874
  itself and offers no client-side flow, so `confirmPriceChange` reports
853
875
  `unsupported` rather than faking a confirmation.
@@ -863,7 +885,12 @@ Your obligations as the app owner — this package cannot discharge them for you
863
885
 
864
886
  ## Adding iOS later
865
887
 
866
- Implement `ProviderPort` for StoreKit. Nothing else changes.
888
+ Implement `ProviderPort` for StoreKit and return it from `createStoreProvider()` on
889
+ iOS. Your entitlement checks, hooks and purchase calls do not change. What an iOS
890
+ release adds: `storeProductIds.ios` in each catalog entry, the adapter's own
891
+ `capabilities()` (StoreKit can tell grace period from account hold, and supports
892
+ externally initiated purchases, so some states Android reports as `indeterminate`
893
+ become distinct), and its conformance run (`runProviderPortConformance`).
867
894
 
868
895
  | Play concept | Port concept | StoreKit |
869
896
  |---|---|---|
@@ -890,6 +917,10 @@ Local storage carries a schema version and a forward migration chain. A record
890
917
  that cannot be migrated is discarded and rebuilt from the store — safe, because
891
918
  discarding yields `unknown`, never a false `not_entitled`.
892
919
 
920
+ Schema version 2 (0.6.0) drops two things version 1 stored and never used: an
921
+ empty `entitlements` map and a `finalized` purchase state nothing set. A version 1
922
+ record is migrated in place on first load.
923
+
893
924
  ### Migrating `0.1.0` to `0.2.0`
894
925
 
895
926
  **One union gained a member**, so an exhaustive `switch` over it needs the new
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chipmobilesdk/rn-payment",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "description": "Reusable in-app payment and entitlement capability for ChipMobileSdk React Native apps",
@@ -68,10 +68,10 @@ rule: a chargeable purchase is permitted only when `environment: 'production'` *
68
68
  `PAYMENT_PRODUCTION_CONFIG_IN_NON_PRODUCTION_BUILD` at construction and never connects.
69
69
 
70
70
  Consequence worth knowing before you plan device testing: **licence-tester purchases are
71
- real Play transactions**, so verifying them needs a release-configuration build
72
- (`isProductionBuild: !__DEV__`, `environment: 'production'`) installed from a Play track.
73
- Debug builds are structurally unable to reach the store. Drive every behaviour that is
74
- not "does Play accept this" through the fake store instead. See
71
+ real Play transactions**. A debug build reaches the store only with `environment: 'test'`
72
+ and `allowStoreTestPurchases: true` (licence testers are not charged; anyone else would
73
+ be); otherwise it gets `environment_blocked`. Drive every behaviour that is not "does
74
+ Play accept this" through the fake store instead. See
75
75
  `## Environments and the production guard`.
76
76
 
77
77
  Unlike `@chipmobilesdk/rn-ads`, there is **no test-id substitution**: Play has no test
@@ -41,11 +41,18 @@ import { createListenerSet, lazyPeer } from '@chipmobilesdk/rn-core';
41
41
  import type { RequestPurchaseProps, SubscriptionReplacementModeAndroid } from 'react-native-iap';
42
42
 
43
43
  import type { ProductType, Unsubscribe } from '../config/types';
44
+ import {
45
+ mapErrorCode,
46
+ normalizeProduct,
47
+ normalizePurchase,
48
+ type RawProduct,
49
+ type RawPurchase,
50
+ type RawPurchaseError,
51
+ } from './iapShapes';
44
52
  import { PaymentError, redact } from '../diagnostics/errors';
45
53
  import type {
46
54
  ProviderCapabilities,
47
55
  ProviderConnectOutcome,
48
- ProviderFailureReason,
49
56
  ProviderPort,
50
57
  ProviderPriceChangeOutcome,
51
58
  ProviderProduct,
@@ -56,6 +63,10 @@ import type {
56
63
  StoreProductId,
57
64
  } from './port';
58
65
 
66
+ // Translation lives in `iapShapes`; it is published from here, where every
67
+ // consumer has always imported it from.
68
+ export { toProviderPurchaseState } from './iapShapes';
69
+
59
70
  /**
60
71
  * Exactly what this adapter needs from the binding.
61
72
  *
@@ -78,67 +89,6 @@ interface IapModule {
78
89
  deepLinkToSubscriptions?(params: { skuAndroid?: string }): Promise<void>;
79
90
  }
80
91
 
81
- interface RawProduct {
82
- id?: string;
83
- productId?: string;
84
- title?: string;
85
- name?: string;
86
- description?: string;
87
- displayPrice?: string;
88
- localizedPrice?: string;
89
- currency?: string;
90
- currencyCode?: string;
91
- price?: string | number;
92
- oneTimePurchaseOfferDetails?: {
93
- formattedPrice?: string;
94
- priceAmountMicros?: string | number;
95
- priceCurrencyCode?: string;
96
- };
97
- subscriptionOfferDetails?: RawSubscriptionOffer[];
98
- }
99
-
100
- interface RawSubscriptionOffer {
101
- offerToken?: string;
102
- offerId?: string;
103
- basePlanId?: string;
104
- pricingPhases?: {
105
- pricingPhaseList?: Array<{
106
- formattedPrice?: string;
107
- priceAmountMicros?: string | number;
108
- priceCurrencyCode?: string;
109
- billingPeriod?: string;
110
- recurrenceMode?: number;
111
- billingCycleCount?: number;
112
- }>;
113
- };
114
- }
115
-
116
- interface RawPurchase {
117
- id?: string;
118
- transactionId?: string;
119
- productId?: string;
120
- ids?: string[];
121
- purchaseToken?: string;
122
- purchaseStateAndroid?: number | null;
123
- /** A string in the 16.x binding: `'pending' | 'purchased' | 'unknown'`. */
124
- purchaseState?: number | string;
125
- transactionDate?: number;
126
- isAcknowledgedAndroid?: boolean;
127
- isAcknowledged?: boolean;
128
- autoRenewingAndroid?: boolean;
129
- isAutoRenewing?: boolean;
130
- expirationDateAndroid?: number;
131
- transactionReceipt?: string;
132
- dataAndroid?: string;
133
- }
134
-
135
- /** What `purchaseErrorListener` delivers. */
136
- interface RawPurchaseError {
137
- code?: string;
138
- message?: string;
139
- productId?: string | null;
140
- }
141
-
142
92
  /**
143
93
  * How long to wait for the outcome event after `requestPurchase` has resolved.
144
94
  * The binding emits the event before or as it resolves, so this only bounds the
@@ -193,12 +143,6 @@ export function toRequestProps(request: ProviderPurchaseRequest): RequestPurchas
193
143
  };
194
144
  }
195
145
 
196
- /** Play's `PurchaseState`: 0 unspecified, 1 purchased, 2 pending. */
197
- const PLAY_STATE_PURCHASED = 1;
198
- const PLAY_STATE_PENDING = 2;
199
-
200
- // Required lazily (rn-core's lazy peer) so importing the package does not require
201
- // the native module.
202
146
  const iapPeer = lazyPeer('react-native-iap', () => {
203
147
  // Inside `try`, so Metro bundles an app that leaves this optional peer out.
204
148
  try {
@@ -220,154 +164,6 @@ function loadModule(): IapModule {
220
164
  );
221
165
  }
222
166
 
223
- function toMicros(value: string | number | undefined): number {
224
- if (typeof value === 'number') {
225
- return value;
226
- }
227
- if (typeof value === 'string') {
228
- const parsed = Number.parseInt(value, 10);
229
- return Number.isFinite(parsed) ? parsed : 0;
230
- }
231
- return 0;
232
- }
233
-
234
- function normalizeProduct(raw: RawProduct, productType: ProductType): ProviderProduct {
235
- const oneTime = raw.oneTimePurchaseOfferDetails;
236
- const currencyCode = oneTime?.priceCurrencyCode ?? raw.currencyCode ?? raw.currency ?? '';
237
-
238
- return {
239
- storeProductId: raw.id ?? raw.productId ?? '',
240
- productType,
241
- title: raw.title ?? raw.name ?? '',
242
- description: raw.description ?? '',
243
- // Store-formatted, never built here: only the store knows the right currency
244
- // symbol, separator, and placement for the user's locale (FR-028).
245
- formattedPrice: oneTime?.formattedPrice ?? raw.displayPrice ?? raw.localizedPrice ?? '',
246
- currencyCode,
247
- priceAmountMicros: toMicros(oneTime?.priceAmountMicros ?? raw.price),
248
- offers: (raw.subscriptionOfferDetails ?? []).map(offer => normalizeOffer(offer, currencyCode)),
249
- };
250
- }
251
-
252
- function normalizeOffer(
253
- offer: RawSubscriptionOffer,
254
- fallbackCurrency: string,
255
- ): ProviderProduct['offers'][number] {
256
- const phases = offer.pricingPhases?.pricingPhaseList ?? [];
257
- // The last phase is the recurring price; earlier ones are trial or
258
- // introductory. Play orders them that way.
259
- const recurring = phases[phases.length - 1];
260
- const intro = phases.length > 1 ? phases[0] : undefined;
261
- const isFreeTrial = intro !== undefined && toMicros(intro.priceAmountMicros) === 0;
262
-
263
- return {
264
- offerKey: offer.offerToken ?? offer.offerId ?? offer.basePlanId ?? '',
265
- billingPeriod: recurring?.billingPeriod ?? '',
266
- formattedPrice: recurring?.formattedPrice ?? '',
267
- priceAmountMicros: toMicros(recurring?.priceAmountMicros),
268
- currencyCode: recurring?.priceCurrencyCode ?? fallbackCurrency,
269
- trialPeriod: isFreeTrial ? intro?.billingPeriod : undefined,
270
- introductoryPrice:
271
- intro && !isFreeTrial
272
- ? {
273
- formattedPrice: intro.formattedPrice ?? '',
274
- period: intro.billingPeriod ?? '',
275
- cycles: intro.billingCycleCount ?? 1,
276
- }
277
- : undefined,
278
- // Play decides eligibility by only returning offers the user qualifies for,
279
- // so forwarding `true` reports the store's determination rather than
280
- // computing our own (FR-026).
281
- isEligible: true,
282
- };
283
- }
284
-
285
- /**
286
- * Reads the purchase state from whichever field the binding filled.
287
- *
288
- * Pending is preserved, never flattened to purchased: that distinction is what
289
- * stops an entitlement being granted for money that has not arrived (FR-037).
290
- * And an unrecognized or missing state is `unspecified`, never `purchased`.
291
- *
292
- * 0.1.0 read only Play's numeric state and treated anything else as purchased.
293
- * The 16.x binding hands JavaScript the *string* state and does not carry the
294
- * numeric one across, so on a device every pending purchase arrived as a string,
295
- * fell through to the default, and was granted. Unit tests could not see it: they
296
- * drive the fake store, which never passes through here.
297
- */
298
- export function toProviderPurchaseState(
299
- raw: Pick<RawPurchase, 'purchaseStateAndroid' | 'purchaseState'>,
300
- ): ProviderPurchase['state'] {
301
- let numeric: number | undefined;
302
- if (typeof raw.purchaseStateAndroid === 'number') {
303
- numeric = raw.purchaseStateAndroid;
304
- } else if (typeof raw.purchaseState === 'number') {
305
- numeric = raw.purchaseState;
306
- }
307
- if (numeric !== undefined) {
308
- if (numeric === PLAY_STATE_PENDING) {
309
- return 'pending';
310
- }
311
- return numeric === PLAY_STATE_PURCHASED ? 'purchased' : 'unspecified';
312
- }
313
-
314
- switch (typeof raw.purchaseState === 'string' ? raw.purchaseState.toLowerCase() : '') {
315
- case 'purchased':
316
- case 'restored':
317
- return 'purchased';
318
- case 'pending':
319
- case 'deferred':
320
- return 'pending';
321
- default:
322
- return 'unspecified';
323
- }
324
- }
325
-
326
- function normalizePurchase(raw: RawPurchase): ProviderPurchase {
327
- return {
328
- storeProductId: raw.productId ?? raw.ids?.[0] ?? '',
329
- transactionId: raw.id ?? raw.transactionId ?? raw.purchaseToken ?? '',
330
- token: raw.purchaseToken ?? '',
331
- state: toProviderPurchaseState(raw),
332
- purchasedAt: raw.transactionDate ?? Date.now(),
333
- isAcknowledged: raw.isAcknowledgedAndroid ?? raw.isAcknowledged ?? false,
334
- expiresAt: raw.expirationDateAndroid,
335
- willAutoRenew: raw.autoRenewingAndroid ?? raw.isAutoRenewing,
336
- // Opaque material for the app's backend. Carried here and handed to the
337
- // validation hook only — never logged or put in an event (FR-074).
338
- verificationPayload: raw.transactionReceipt ?? raw.dataAndroid,
339
- };
340
- }
341
-
342
- /** Maps a binding error code onto the port's vocabulary, so no store code escapes. */
343
- function mapErrorCode(code: string | undefined): ProviderFailureReason {
344
- switch (code) {
345
- case 'E_USER_CANCELLED':
346
- case 'user-cancelled':
347
- return 'user_cancelled';
348
- case 'E_ALREADY_OWNED':
349
- case 'already-owned':
350
- case 'item-already-owned':
351
- return 'already_owned';
352
- case 'E_ITEM_UNAVAILABLE':
353
- case 'item-unavailable':
354
- case 'sku-not-found':
355
- return 'unknown_product';
356
- case 'E_NOT_PREPARED':
357
- case 'not-prepared':
358
- return 'not_ready';
359
- case 'E_NETWORK_ERROR':
360
- case 'network-error':
361
- return 'network_error';
362
- case 'E_DEFERRED_PAYMENT':
363
- // Not a failure — a pending purchase. The caller resolves it from the
364
- // purchase update rather than from this error.
365
- return 'store_error';
366
- default:
367
- return 'store_error';
368
- }
369
- }
370
-
371
167
  const ANDROID_CAPABILITIES: ProviderCapabilities = {
372
168
  supportsSubscriptions: true,
373
169
  supportsPlanChange: true,