@chipmobilesdk/rn-payment 0.3.1 → 0.4.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 +334 -289
- package/README.md +8 -1
- package/package.json +54 -54
- package/skills/sdk-rn-payment/SKILL.md +174 -174
- package/src/adapter/iap-adapter.ts +4 -0
- package/src/config/types.ts +15 -0
- package/src/core/timers.ts +18 -0
- package/src/diagnostics/errors.ts +11 -4
- package/src/engine/engine.ts +62 -14
- package/src/engine/finalize.ts +98 -50
- package/src/engine/purchase.ts +76 -14
- package/src/testing/fakeStore.ts +16 -0
package/README.md
CHANGED
|
@@ -574,7 +574,14 @@ outage. The queued transaction is re-validated on a later sync, and a rejection
|
|
|
574
574
|
then revokes — which bounds the exposure this default accepts. Choose `'withhold'`
|
|
575
575
|
if your threat model requires it.
|
|
576
576
|
|
|
577
|
-
A hook that throws is treated as unavailable, never as an implicit approval.
|
|
577
|
+
A hook that throws is treated as unavailable, never as an implicit approval. So is
|
|
578
|
+
one that does not answer within `timeouts.validateMs` (default 15 s): the purchase
|
|
579
|
+
result and the purchase lock never wait on your backend indefinitely.
|
|
580
|
+
|
|
581
|
+
**Re-validation carries `transactionId`, not a payload.** Verification material is
|
|
582
|
+
never persisted, so a queued transaction is re-checked with `verificationPayload: ''`.
|
|
583
|
+
Every candidate carries `transactionId` (Play's order id) and `platform`, which is
|
|
584
|
+
what your backend looks the purchase up by on that second call.
|
|
578
585
|
|
|
579
586
|
This package calls **no endpoint** other than the platform's billing service. All
|
|
580
587
|
other traffic is your own code, through this hook.
|
package/package.json
CHANGED
|
@@ -1,54 +1,54 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@chipmobilesdk/rn-payment",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"private": false,
|
|
5
|
-
"license": "UNLICENSED",
|
|
6
|
-
"description": "Reusable in-app payment and entitlement capability for ChipMobileSdk React Native apps",
|
|
7
|
-
"main": "src/index.ts",
|
|
8
|
-
"types": "src/index.ts",
|
|
9
|
-
"exports": {
|
|
10
|
-
".": {
|
|
11
|
-
"types": "./src/index.ts",
|
|
12
|
-
"react-native": "./src/index.ts",
|
|
13
|
-
"default": "./src/index.ts"
|
|
14
|
-
},
|
|
15
|
-
"./testing": {
|
|
16
|
-
"types": "./src/testing/index.ts",
|
|
17
|
-
"react-native": "./src/testing/index.ts",
|
|
18
|
-
"default": "./src/testing/index.ts"
|
|
19
|
-
}
|
|
20
|
-
},
|
|
21
|
-
"files": [
|
|
22
|
-
"src/index.ts",
|
|
23
|
-
"src/adapter",
|
|
24
|
-
"src/config",
|
|
25
|
-
"src/core",
|
|
26
|
-
"src/diagnostics",
|
|
27
|
-
"src/engine",
|
|
28
|
-
"src/entitlements",
|
|
29
|
-
"src/persistence",
|
|
30
|
-
"src/react",
|
|
31
|
-
"src/testing",
|
|
32
|
-
"skills",
|
|
33
|
-
"README.md",
|
|
34
|
-
"CHANGELOG.md"
|
|
35
|
-
],
|
|
36
|
-
"sideEffects": false,
|
|
37
|
-
"scripts": {
|
|
38
|
-
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
39
|
-
"pack:dry-run": "npm pack --dry-run"
|
|
40
|
-
},
|
|
41
|
-
"peerDependencies": {
|
|
42
|
-
"react": ">=19",
|
|
43
|
-
"react-native": ">=0.85",
|
|
44
|
-
"react-native-iap": ">=16.7.3 <17",
|
|
45
|
-
"react-native-mmkv": ">=4.0.0",
|
|
46
|
-
"react-native-nitro-modules": ">=0.36.5"
|
|
47
|
-
},
|
|
48
|
-
"keywords": [
|
|
49
|
-
"react-native",
|
|
50
|
-
"in-app-purchase",
|
|
51
|
-
"subscriptions",
|
|
52
|
-
"entitlements"
|
|
53
|
-
]
|
|
54
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@chipmobilesdk/rn-payment",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"description": "Reusable in-app payment and entitlement capability for ChipMobileSdk React Native apps",
|
|
7
|
+
"main": "src/index.ts",
|
|
8
|
+
"types": "src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./src/index.ts",
|
|
12
|
+
"react-native": "./src/index.ts",
|
|
13
|
+
"default": "./src/index.ts"
|
|
14
|
+
},
|
|
15
|
+
"./testing": {
|
|
16
|
+
"types": "./src/testing/index.ts",
|
|
17
|
+
"react-native": "./src/testing/index.ts",
|
|
18
|
+
"default": "./src/testing/index.ts"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"src/index.ts",
|
|
23
|
+
"src/adapter",
|
|
24
|
+
"src/config",
|
|
25
|
+
"src/core",
|
|
26
|
+
"src/diagnostics",
|
|
27
|
+
"src/engine",
|
|
28
|
+
"src/entitlements",
|
|
29
|
+
"src/persistence",
|
|
30
|
+
"src/react",
|
|
31
|
+
"src/testing",
|
|
32
|
+
"skills",
|
|
33
|
+
"README.md",
|
|
34
|
+
"CHANGELOG.md"
|
|
35
|
+
],
|
|
36
|
+
"sideEffects": false,
|
|
37
|
+
"scripts": {
|
|
38
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
39
|
+
"pack:dry-run": "npm pack --dry-run"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"react": ">=19",
|
|
43
|
+
"react-native": ">=0.85",
|
|
44
|
+
"react-native-iap": ">=16.7.3 <17",
|
|
45
|
+
"react-native-mmkv": ">=4.0.0",
|
|
46
|
+
"react-native-nitro-modules": ">=0.36.5"
|
|
47
|
+
},
|
|
48
|
+
"keywords": [
|
|
49
|
+
"react-native",
|
|
50
|
+
"in-app-purchase",
|
|
51
|
+
"subscriptions",
|
|
52
|
+
"entitlements"
|
|
53
|
+
]
|
|
54
|
+
}
|
|
@@ -1,174 +1,174 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: sdk-rn-payment
|
|
3
|
-
description: "In-app payments via @chipmobilesdk/rn-payment: Play Billing one-time/consumable/subscription behind a store-neutral contract, entitlement keys with a required unknown state, pending purchases visible across sessions, guaranteed transaction finalization, per-environment production-transaction blocking. Use when selling a feature, gating on an entitlement, showing a payment still pending after a restart, debugging a purchase that returns environment_blocked or an entitlement stuck at unknown, mapping Play products to entitlement keys, or answering a Data safety question about purchases."
|
|
4
|
-
metadata:
|
|
5
|
-
package: "@chipmobilesdk/rn-payment"
|
|
6
|
-
packageVersion: "0.3.1"
|
|
7
|
-
user-invocable: true
|
|
8
|
-
disable-model-invocation: false
|
|
9
|
-
---
|
|
10
|
-
|
|
11
|
-
# @chipmobilesdk/rn-payment
|
|
12
|
-
|
|
13
|
-
One-time, consumable and subscription purchases on Android via Google Play Billing,
|
|
14
|
-
behind a contract that names no store. The app asks about **entitlement keys** it chose;
|
|
15
|
-
the package owns the transaction. Consuming code never imports the billing library.
|
|
16
|
-
|
|
17
|
-
## Use this when
|
|
18
|
-
|
|
19
|
-
- Selling a feature, or gating one on an entitlement
|
|
20
|
-
- Showing "payment pending" — in this session or after a restart — and withholding Buy
|
|
21
|
-
- Mapping Play product ids to entitlement keys
|
|
22
|
-
- Debugging a purchase that returns `environment_blocked`, or an entitlement stuck at
|
|
23
|
-
`unknown`
|
|
24
|
-
- Wiring `no_ads` (or any entitlement) to another system
|
|
25
|
-
- Answering a Data safety / Play policy question about purchases
|
|
26
|
-
|
|
27
|
-
## Do not use this for
|
|
28
|
-
|
|
29
|
-
- **Paywall UI, pricing strategy, or when to offer** — the package ships no paywall, no
|
|
30
|
-
price table and no purchase button, and chooses no moment.
|
|
31
|
-
- **What an entitlement unlocks** — it reports `entitled`; the meaning is the app's.
|
|
32
|
-
- **Cancellation UI** — platforms forbid a self-built one. Use
|
|
33
|
-
`openSubscriptionManagement()`, which opens the store's.
|
|
34
|
-
- **Receipt verification** — `validateTransaction` is a hook for *your* backend; the
|
|
35
|
-
package calls no endpoint but the billing service.
|
|
36
|
-
- iOS — contract only. `createStoreProvider()` returns an `unavailable` stub.
|
|
37
|
-
|
|
38
|
-
## `unknown` is a real state, and collapsing it is the defect this package exists to prevent
|
|
39
|
-
|
|
40
|
-
`getEntitlement()` returns `unknown | entitled | not_entitled`. On a cold start the answer
|
|
41
|
-
is genuinely not known yet, and the package says so rather than guessing. Treat `unknown`
|
|
42
|
-
as "wait", never as "unpaid":
|
|
43
|
-
|
|
44
|
-
```tsx
|
|
45
|
-
if (state.status === 'unknown') return <Loading />; // not the unpaid branch
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
Collapse it and a paying user watches ads flash before they vanish, or sees a purchased
|
|
49
|
-
feature locked for a second. It is also **re-entrant** — a cached answer returns to
|
|
50
|
-
`unknown` past its staleness ceiling (7 days by default), so it is not merely a startup
|
|
51
|
-
flicker. See `## The entitlement model`.
|
|
52
|
-
|
|
53
|
-
## Pending is observable across sessions — never keep your own marker
|
|
54
|
-
|
|
55
|
-
New in 0.2.0. `usePendingPurchases(productKeys?)` (or `getPendingPurchases()` /
|
|
56
|
-
`subscribePendingPurchases()`) lists `{ productKey, since }` for payments still in flight,
|
|
57
|
-
read from the persisted snapshot, so it survives an app kill and an offline restart. Same
|
|
58
|
-
rule as entitlements: check `status` first — `unknown` is not "nothing pending". Show the
|
|
59
|
-
existing `payment.state.pending` message key and withhold Buy while listed.
|
|
60
|
-
`purchase_pending_resolved` (`settled` → then `entitlement_granted`, or `cancelled` /
|
|
61
|
-
`expired`) fires once when it ends. On Android an unpaid end is always `cancelled`: Play
|
|
62
|
-
does not say why. See `### A payment that stays pending` under `## Purchase flow`.
|
|
63
|
-
|
|
64
|
-
## A debug build cannot buy anything, and that is the guard working
|
|
65
|
-
|
|
66
|
-
`purchase()` returning `environment_blocked` in development is correct, not a bug. The
|
|
67
|
-
rule: a chargeable purchase is permitted only when `environment: 'production'` **and**
|
|
68
|
-
`isProductionBuild: true`. A non-production build declaring `production` throws
|
|
69
|
-
`PAYMENT_PRODUCTION_CONFIG_IN_NON_PRODUCTION_BUILD` at construction and never connects.
|
|
70
|
-
|
|
71
|
-
Consequence worth knowing before you plan device testing: **licence-tester purchases are
|
|
72
|
-
real Play transactions**, so verifying them needs a release-configuration build
|
|
73
|
-
(`isProductionBuild: !__DEV__`, `environment: 'production'`) installed from a Play track.
|
|
74
|
-
Debug builds are structurally unable to reach the store. Drive every behaviour that is
|
|
75
|
-
not "does Play accept this" through the fake store instead. See
|
|
76
|
-
`## Environments and the production guard`.
|
|
77
|
-
|
|
78
|
-
Unlike `@chipmobilesdk/rn-ads`, there is **no test-id substitution**: Play has no test
|
|
79
|
-
*product* id, so blocking is the only honest option.
|
|
80
|
-
|
|
81
|
-
## Two more things not to "fix"
|
|
82
|
-
|
|
83
|
-
- **Finalization is not yours to call** for non-consumables. Google auto-refunds an
|
|
84
|
-
unacknowledged purchase after three days, so the package owns a persisted queue drained
|
|
85
|
-
at startup and on foreground. A **consumable** is the exception: call `consume()` after
|
|
86
|
-
*your* data records the item, because only you know it was banked.
|
|
87
|
-
- **`renewalState: 'indeterminate'` on Android is honest.** Play returns a grace-period
|
|
88
|
-
subscription as active and stops returning a held one, so the two are not separable
|
|
89
|
-
client-side. The default policy (`grant` in grace, `withhold` on hold) is nonetheless
|
|
90
|
-
correct with no backend, because Play's own behaviour matches it. A *non-default* policy
|
|
91
|
-
needs server data through the validation hook.
|
|
92
|
-
|
|
93
|
-
Call `handleForeground()` yourself on app resume — the package will not guess what
|
|
94
|
-
"foreground" means for a host backgrounded by its own modal or the store's purchase sheet.
|
|
95
|
-
|
|
96
|
-
## Public API (v0.
|
|
97
|
-
|
|
98
|
-
Runtime: `createEngine` (throws `PaymentConfigError` on a bad catalog or an unsafe
|
|
99
|
-
environment; everything else returns a typed result) · `createStoreProvider` ·
|
|
100
|
-
`PaymentProvider`, `usePaymentEngine` · `useEntitlement`, `useEntitlements`,
|
|
101
|
-
`usePendingPurchases`, `usePaymentStatus`, `usePurchase`, `useProducts`, `useSync` ·
|
|
102
|
-
`PaymentError`,
|
|
103
|
-
`PaymentConfigError`, `PAYMENT_ERROR_CODES` · `DEFAULT_MESSAGES`,
|
|
104
|
-
`DEFAULT_USER_MESSAGES`, `DEFAULT_DEVELOPER_MESSAGES`, `USER_MESSAGE_KEYS`,
|
|
105
|
-
`DEVELOPER_MESSAGE_KEYS`, `isUserMessageKey` · timeout/retry/cache defaults and bounds
|
|
106
|
-
(`DEFAULT_STALE_AFTER_MS`, `DEFAULT_PURCHASE_LAUNCH_TIMEOUT_MS`, `STALE_AFTER_BOUNDS`, …)
|
|
107
|
-
· `createFakeStore`, `makePurchase` from `@chipmobilesdk/rn-payment/testing` (test
|
|
108
|
-
doubles only).
|
|
109
|
-
|
|
110
|
-
Engine: `initialize`, `cleanup`, `getStatus`, `subscribeStatus`, `getEntitlement`,
|
|
111
|
-
`getAllEntitlements`, `subscribeEntitlements`, `getPendingPurchases`,
|
|
112
|
-
`subscribePendingPurchases`, `purchase`, `consume`, `getUnfinalized`,
|
|
113
|
-
`getProducts`, `refreshProducts`, `changePlan`, `openSubscriptionManagement`,
|
|
114
|
-
`confirmPriceChange`, `sync`, `restore`, `getLastSyncedAt`, `clearLocalData`,
|
|
115
|
-
`handleForeground`, `subscribeEvents`.
|
|
116
|
-
|
|
117
|
-
Types: `PaymentConfig`, `CatalogEntry`, `ProductType`, `StoreProductIds`,
|
|
118
|
-
`EntitlementKey`, `ProductKey`, `EntitlementState`, `EntitlementStatus`,
|
|
119
|
-
`EntitlementMap`, `SubscriptionDetail`, `RenewalState`, `PendingPurchase`,
|
|
120
|
-
`PendingPurchasesState`, `PendingResolution`, `PurchaseResult`,
|
|
121
|
-
`PurchaseOptions`, `TransactionRef`, `FinalizeResult`, `UnfinalizedSummary`,
|
|
122
|
-
`ProductInfo`, `Offer`, `ProductQueryResult`, `MissingProduct`, `ProrationMode`,
|
|
123
|
-
`PriceChangeResult`, `PaymentStatus`, `PaymentStatusKind`, `UnavailableReason`,
|
|
124
|
-
`Environment`, `LapsePolicy`, `ValidationHook`, `ValidationCandidate`,
|
|
125
|
-
`ValidationDecision`, `ValidationUnavailablePolicy`, `SyncOutcome`, `RestoreOutcome`,
|
|
126
|
-
`InitOutcome`, `PaymentEvent`, `PaymentErrorCode`, `LoggerAdapter`, `Unsubscribe`.
|
|
127
|
-
|
|
128
|
-
`cleanup()` is idempotent, and so is every `Unsubscribe`. After cleanup no event fires.
|
|
129
|
-
|
|
130
|
-
## Upgrading 0.1.0 → 0.2.0
|
|
131
|
-
|
|
132
|
-
One compile-time change: `PaymentEventType` gained `'purchase_pending_resolved'`, so an
|
|
133
|
-
exhaustive `switch` needs the case or a `default`. A hand-written `PaymentEngine` mock
|
|
134
|
-
needs the two pending methods. No storage migration (schema unchanged). Replace any
|
|
135
|
-
app-side "pending since" marker with `usePendingPurchases`.
|
|
136
|
-
|
|
137
|
-
**Require `0.3.1` or later on Android.** Up to `0.3.0` no purchase could complete on a
|
|
138
|
-
device: the adapter sent `react-native-iap`'s pre-16.7 request key (`android`), rejected
|
|
139
|
-
before Play opens, and read the binding's always-empty resolved value instead of its
|
|
140
|
-
outcome events. `0.3.1` needs `react-native-iap` >= 16.7.3. If a purchase returns
|
|
141
|
-
`store_error` at once, check the installed binding against that range first.
|
|
142
|
-
|
|
143
|
-
**Require `0.2.1` or later.** Earlier versions opened MMKV with the v3 constructor, which
|
|
144
|
-
react-native-mmkv v4 does not have, so on a device nothing persisted: owners read
|
|
145
|
-
`unknown` after every cold start and pending payments vanished after a kill.
|
|
146
|
-
|
|
147
|
-
**Upgrade before shipping on Android.** In `0.1.0` the adapter misread `react-native-iap`
|
|
148
|
-
16.x's string purchase state, so on a device a pending purchase was granted. `0.2.0`
|
|
149
|
-
reads it correctly, and an unrecognized (`unspecified`) state never grants or finalizes.
|
|
150
|
-
|
|
151
|
-
## Where the detail lives
|
|
152
|
-
|
|
153
|
-
Read one section of `node_modules/@chipmobilesdk/rn-payment/README.md`:
|
|
154
|
-
|
|
155
|
-
| Need | Section |
|
|
156
|
-
|---|---|
|
|
157
|
-
| First integration | `## Install`, `## Quick start` |
|
|
158
|
-
| Catalog and Play Console product setup | `## Catalog and entitlement mapping` |
|
|
159
|
-
| Entitlement states and staleness | `## The entitlement model` |
|
|
160
|
-
| Every purchase outcome and how to handle it | `## Purchase flow` |
|
|
161
|
-
| A payment pending across sessions, and how it ends | `### A payment that stays pending` |
|
|
162
|
-
| Why acknowledgement is not yours | `## Finalization: the invariant you cannot opt out of` |
|
|
163
|
-
| Consumables | `## Consumables` |
|
|
164
|
-
| Grace/hold, plan change, cancellation | `## Subscriptions` |
|
|
165
|
-
| Prices, offers, trial eligibility | `## Prices and offers` |
|
|
166
|
-
| Restore, offline, logout, account switch | `## Sync, restore, and offline` |
|
|
167
|
-
| `environment_blocked`, release config | `## Environments and the production guard` |
|
|
168
|
-
| Backend verification | `## Server-side validation` |
|
|
169
|
-
| Events, ordering, error codes | `## Events and errors` |
|
|
170
|
-
| Wiring an entitlement to ads | `## Wiring an entitlement to another system` |
|
|
171
|
-
| Tests and licence testers | `## Testing` |
|
|
172
|
-
| Data safety, permissions | `## What is stored on the device`, `## Play policy checklist` |
|
|
173
|
-
| Before release | `## Production release checklist` |
|
|
174
|
-
| What is deferred | `## Known limitations`, `## Adding iOS later` |
|
|
1
|
+
---
|
|
2
|
+
name: sdk-rn-payment
|
|
3
|
+
description: "In-app payments via @chipmobilesdk/rn-payment: Play Billing one-time/consumable/subscription behind a store-neutral contract, entitlement keys with a required unknown state, pending purchases visible across sessions, guaranteed transaction finalization, per-environment production-transaction blocking. Use when selling a feature, gating on an entitlement, showing a payment still pending after a restart, debugging a purchase that returns environment_blocked or an entitlement stuck at unknown, mapping Play products to entitlement keys, or answering a Data safety question about purchases."
|
|
4
|
+
metadata:
|
|
5
|
+
package: "@chipmobilesdk/rn-payment"
|
|
6
|
+
packageVersion: "0.3.1"
|
|
7
|
+
user-invocable: true
|
|
8
|
+
disable-model-invocation: false
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# @chipmobilesdk/rn-payment
|
|
12
|
+
|
|
13
|
+
One-time, consumable and subscription purchases on Android via Google Play Billing,
|
|
14
|
+
behind a contract that names no store. The app asks about **entitlement keys** it chose;
|
|
15
|
+
the package owns the transaction. Consuming code never imports the billing library.
|
|
16
|
+
|
|
17
|
+
## Use this when
|
|
18
|
+
|
|
19
|
+
- Selling a feature, or gating one on an entitlement
|
|
20
|
+
- Showing "payment pending" — in this session or after a restart — and withholding Buy
|
|
21
|
+
- Mapping Play product ids to entitlement keys
|
|
22
|
+
- Debugging a purchase that returns `environment_blocked`, or an entitlement stuck at
|
|
23
|
+
`unknown`
|
|
24
|
+
- Wiring `no_ads` (or any entitlement) to another system
|
|
25
|
+
- Answering a Data safety / Play policy question about purchases
|
|
26
|
+
|
|
27
|
+
## Do not use this for
|
|
28
|
+
|
|
29
|
+
- **Paywall UI, pricing strategy, or when to offer** — the package ships no paywall, no
|
|
30
|
+
price table and no purchase button, and chooses no moment.
|
|
31
|
+
- **What an entitlement unlocks** — it reports `entitled`; the meaning is the app's.
|
|
32
|
+
- **Cancellation UI** — platforms forbid a self-built one. Use
|
|
33
|
+
`openSubscriptionManagement()`, which opens the store's.
|
|
34
|
+
- **Receipt verification** — `validateTransaction` is a hook for *your* backend; the
|
|
35
|
+
package calls no endpoint but the billing service.
|
|
36
|
+
- iOS — contract only. `createStoreProvider()` returns an `unavailable` stub.
|
|
37
|
+
|
|
38
|
+
## `unknown` is a real state, and collapsing it is the defect this package exists to prevent
|
|
39
|
+
|
|
40
|
+
`getEntitlement()` returns `unknown | entitled | not_entitled`. On a cold start the answer
|
|
41
|
+
is genuinely not known yet, and the package says so rather than guessing. Treat `unknown`
|
|
42
|
+
as "wait", never as "unpaid":
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
if (state.status === 'unknown') return <Loading />; // not the unpaid branch
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Collapse it and a paying user watches ads flash before they vanish, or sees a purchased
|
|
49
|
+
feature locked for a second. It is also **re-entrant** — a cached answer returns to
|
|
50
|
+
`unknown` past its staleness ceiling (7 days by default), so it is not merely a startup
|
|
51
|
+
flicker. See `## The entitlement model`.
|
|
52
|
+
|
|
53
|
+
## Pending is observable across sessions — never keep your own marker
|
|
54
|
+
|
|
55
|
+
New in 0.2.0. `usePendingPurchases(productKeys?)` (or `getPendingPurchases()` /
|
|
56
|
+
`subscribePendingPurchases()`) lists `{ productKey, since }` for payments still in flight,
|
|
57
|
+
read from the persisted snapshot, so it survives an app kill and an offline restart. Same
|
|
58
|
+
rule as entitlements: check `status` first — `unknown` is not "nothing pending". Show the
|
|
59
|
+
existing `payment.state.pending` message key and withhold Buy while listed.
|
|
60
|
+
`purchase_pending_resolved` (`settled` → then `entitlement_granted`, or `cancelled` /
|
|
61
|
+
`expired`) fires once when it ends. On Android an unpaid end is always `cancelled`: Play
|
|
62
|
+
does not say why. See `### A payment that stays pending` under `## Purchase flow`.
|
|
63
|
+
|
|
64
|
+
## A debug build cannot buy anything, and that is the guard working
|
|
65
|
+
|
|
66
|
+
`purchase()` returning `environment_blocked` in development is correct, not a bug. The
|
|
67
|
+
rule: a chargeable purchase is permitted only when `environment: 'production'` **and**
|
|
68
|
+
`isProductionBuild: true`. A non-production build declaring `production` throws
|
|
69
|
+
`PAYMENT_PRODUCTION_CONFIG_IN_NON_PRODUCTION_BUILD` at construction and never connects.
|
|
70
|
+
|
|
71
|
+
Consequence worth knowing before you plan device testing: **licence-tester purchases are
|
|
72
|
+
real Play transactions**, so verifying them needs a release-configuration build
|
|
73
|
+
(`isProductionBuild: !__DEV__`, `environment: 'production'`) installed from a Play track.
|
|
74
|
+
Debug builds are structurally unable to reach the store. Drive every behaviour that is
|
|
75
|
+
not "does Play accept this" through the fake store instead. See
|
|
76
|
+
`## Environments and the production guard`.
|
|
77
|
+
|
|
78
|
+
Unlike `@chipmobilesdk/rn-ads`, there is **no test-id substitution**: Play has no test
|
|
79
|
+
*product* id, so blocking is the only honest option.
|
|
80
|
+
|
|
81
|
+
## Two more things not to "fix"
|
|
82
|
+
|
|
83
|
+
- **Finalization is not yours to call** for non-consumables. Google auto-refunds an
|
|
84
|
+
unacknowledged purchase after three days, so the package owns a persisted queue drained
|
|
85
|
+
at startup and on foreground. A **consumable** is the exception: call `consume()` after
|
|
86
|
+
*your* data records the item, because only you know it was banked.
|
|
87
|
+
- **`renewalState: 'indeterminate'` on Android is honest.** Play returns a grace-period
|
|
88
|
+
subscription as active and stops returning a held one, so the two are not separable
|
|
89
|
+
client-side. The default policy (`grant` in grace, `withhold` on hold) is nonetheless
|
|
90
|
+
correct with no backend, because Play's own behaviour matches it. A *non-default* policy
|
|
91
|
+
needs server data through the validation hook.
|
|
92
|
+
|
|
93
|
+
Call `handleForeground()` yourself on app resume — the package will not guess what
|
|
94
|
+
"foreground" means for a host backgrounded by its own modal or the store's purchase sheet.
|
|
95
|
+
|
|
96
|
+
## Public API (v0.4.0)
|
|
97
|
+
|
|
98
|
+
Runtime: `createEngine` (throws `PaymentConfigError` on a bad catalog or an unsafe
|
|
99
|
+
environment; everything else returns a typed result) · `createStoreProvider` ·
|
|
100
|
+
`PaymentProvider`, `usePaymentEngine` · `useEntitlement`, `useEntitlements`,
|
|
101
|
+
`usePendingPurchases`, `usePaymentStatus`, `usePurchase`, `useProducts`, `useSync` ·
|
|
102
|
+
`PaymentError`,
|
|
103
|
+
`PaymentConfigError`, `PAYMENT_ERROR_CODES` · `DEFAULT_MESSAGES`,
|
|
104
|
+
`DEFAULT_USER_MESSAGES`, `DEFAULT_DEVELOPER_MESSAGES`, `USER_MESSAGE_KEYS`,
|
|
105
|
+
`DEVELOPER_MESSAGE_KEYS`, `isUserMessageKey` · timeout/retry/cache defaults and bounds
|
|
106
|
+
(`DEFAULT_STALE_AFTER_MS`, `DEFAULT_PURCHASE_LAUNCH_TIMEOUT_MS`, `STALE_AFTER_BOUNDS`, …)
|
|
107
|
+
· `createFakeStore`, `makePurchase` from `@chipmobilesdk/rn-payment/testing` (test
|
|
108
|
+
doubles only).
|
|
109
|
+
|
|
110
|
+
Engine: `initialize`, `cleanup`, `getStatus`, `subscribeStatus`, `getEntitlement`,
|
|
111
|
+
`getAllEntitlements`, `subscribeEntitlements`, `getPendingPurchases`,
|
|
112
|
+
`subscribePendingPurchases`, `purchase`, `consume`, `getUnfinalized`,
|
|
113
|
+
`getProducts`, `refreshProducts`, `changePlan`, `openSubscriptionManagement`,
|
|
114
|
+
`confirmPriceChange`, `sync`, `restore`, `getLastSyncedAt`, `clearLocalData`,
|
|
115
|
+
`handleForeground`, `subscribeEvents`.
|
|
116
|
+
|
|
117
|
+
Types: `PaymentConfig`, `CatalogEntry`, `ProductType`, `StoreProductIds`,
|
|
118
|
+
`EntitlementKey`, `ProductKey`, `EntitlementState`, `EntitlementStatus`,
|
|
119
|
+
`EntitlementMap`, `SubscriptionDetail`, `RenewalState`, `PendingPurchase`,
|
|
120
|
+
`PendingPurchasesState`, `PendingResolution`, `PurchaseResult`,
|
|
121
|
+
`PurchaseOptions`, `TransactionRef`, `FinalizeResult`, `UnfinalizedSummary`,
|
|
122
|
+
`ProductInfo`, `Offer`, `ProductQueryResult`, `MissingProduct`, `ProrationMode`,
|
|
123
|
+
`PriceChangeResult`, `PaymentStatus`, `PaymentStatusKind`, `UnavailableReason`,
|
|
124
|
+
`Environment`, `LapsePolicy`, `ValidationHook`, `ValidationCandidate`,
|
|
125
|
+
`ValidationDecision`, `ValidationUnavailablePolicy`, `SyncOutcome`, `RestoreOutcome`,
|
|
126
|
+
`InitOutcome`, `PaymentEvent`, `PaymentErrorCode`, `LoggerAdapter`, `Unsubscribe`.
|
|
127
|
+
|
|
128
|
+
`cleanup()` is idempotent, and so is every `Unsubscribe`. After cleanup no event fires.
|
|
129
|
+
|
|
130
|
+
## Upgrading 0.1.0 → 0.2.0
|
|
131
|
+
|
|
132
|
+
One compile-time change: `PaymentEventType` gained `'purchase_pending_resolved'`, so an
|
|
133
|
+
exhaustive `switch` needs the case or a `default`. A hand-written `PaymentEngine` mock
|
|
134
|
+
needs the two pending methods. No storage migration (schema unchanged). Replace any
|
|
135
|
+
app-side "pending since" marker with `usePendingPurchases`.
|
|
136
|
+
|
|
137
|
+
**Require `0.3.1` or later on Android.** Up to `0.3.0` no purchase could complete on a
|
|
138
|
+
device: the adapter sent `react-native-iap`'s pre-16.7 request key (`android`), rejected
|
|
139
|
+
before Play opens, and read the binding's always-empty resolved value instead of its
|
|
140
|
+
outcome events. `0.3.1` needs `react-native-iap` >= 16.7.3. If a purchase returns
|
|
141
|
+
`store_error` at once, check the installed binding against that range first.
|
|
142
|
+
|
|
143
|
+
**Require `0.2.1` or later.** Earlier versions opened MMKV with the v3 constructor, which
|
|
144
|
+
react-native-mmkv v4 does not have, so on a device nothing persisted: owners read
|
|
145
|
+
`unknown` after every cold start and pending payments vanished after a kill.
|
|
146
|
+
|
|
147
|
+
**Upgrade before shipping on Android.** In `0.1.0` the adapter misread `react-native-iap`
|
|
148
|
+
16.x's string purchase state, so on a device a pending purchase was granted. `0.2.0`
|
|
149
|
+
reads it correctly, and an unrecognized (`unspecified`) state never grants or finalizes.
|
|
150
|
+
|
|
151
|
+
## Where the detail lives
|
|
152
|
+
|
|
153
|
+
Read one section of `node_modules/@chipmobilesdk/rn-payment/README.md`:
|
|
154
|
+
|
|
155
|
+
| Need | Section |
|
|
156
|
+
|---|---|
|
|
157
|
+
| First integration | `## Install`, `## Quick start` |
|
|
158
|
+
| Catalog and Play Console product setup | `## Catalog and entitlement mapping` |
|
|
159
|
+
| Entitlement states and staleness | `## The entitlement model` |
|
|
160
|
+
| Every purchase outcome and how to handle it | `## Purchase flow` |
|
|
161
|
+
| A payment pending across sessions, and how it ends | `### A payment that stays pending` |
|
|
162
|
+
| Why acknowledgement is not yours | `## Finalization: the invariant you cannot opt out of` |
|
|
163
|
+
| Consumables | `## Consumables` |
|
|
164
|
+
| Grace/hold, plan change, cancellation | `## Subscriptions` |
|
|
165
|
+
| Prices, offers, trial eligibility | `## Prices and offers` |
|
|
166
|
+
| Restore, offline, logout, account switch | `## Sync, restore, and offline` |
|
|
167
|
+
| `environment_blocked`, release config | `## Environments and the production guard` |
|
|
168
|
+
| Backend verification | `## Server-side validation` |
|
|
169
|
+
| Events, ordering, error codes | `## Events and errors` |
|
|
170
|
+
| Wiring an entitlement to ads | `## Wiring an entitlement to another system` |
|
|
171
|
+
| Tests and licence testers | `## Testing` |
|
|
172
|
+
| Data safety, permissions | `## What is stored on the device`, `## Play policy checklist` |
|
|
173
|
+
| Before release | `## Production release checklist` |
|
|
174
|
+
| What is deferred | `## Known limitations`, `## Adding iOS later` |
|
|
@@ -433,6 +433,10 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
|
|
|
433
433
|
};
|
|
434
434
|
|
|
435
435
|
const attachListeners = (): void => {
|
|
436
|
+
// Every connect attaches, so whatever an earlier connect left must go first.
|
|
437
|
+
// Overwriting the handles orphaned the old native listeners, which kept
|
|
438
|
+
// firing — every purchase update reached the engine once per connect.
|
|
439
|
+
detachListeners();
|
|
436
440
|
const mod = module();
|
|
437
441
|
updateSub = mod.purchaseUpdatedListener(raw => {
|
|
438
442
|
const purchase = normalizePurchase(raw);
|
package/src/config/types.ts
CHANGED
|
@@ -63,7 +63,14 @@ export interface TimeoutConfig {
|
|
|
63
63
|
syncMs?: number;
|
|
64
64
|
/** Covers opening the store surface only. Time the user spends in it is not counted (FR-035). */
|
|
65
65
|
purchaseLaunchMs?: number;
|
|
66
|
+
/** Bounds each acknowledge or consume call; a timed-out attempt is retried. */
|
|
66
67
|
finalizeAttemptMs?: number;
|
|
68
|
+
/**
|
|
69
|
+
* Bounds the app's `validateTransaction` hook. A hook that does not answer in
|
|
70
|
+
* time is treated as unavailable, exactly like one that throws (FR-072b).
|
|
71
|
+
* Added in 0.3.2.
|
|
72
|
+
*/
|
|
73
|
+
validateMs?: number;
|
|
67
74
|
}
|
|
68
75
|
|
|
69
76
|
export interface RetryConfig {
|
|
@@ -88,6 +95,14 @@ export interface ValidationCandidate {
|
|
|
88
95
|
verificationPayload: string;
|
|
89
96
|
/** Ties this decision to one purchase cycle in the event stream (FR-076). */
|
|
90
97
|
correlationId: string;
|
|
98
|
+
/**
|
|
99
|
+
* The store's identifier for this transaction (Play's order id). Not a secret,
|
|
100
|
+
* and the only handle a backend has on re-validation, where
|
|
101
|
+
* `verificationPayload` is empty because it is never persisted. Added in 0.3.2.
|
|
102
|
+
*/
|
|
103
|
+
transactionId: string;
|
|
104
|
+
/** Which store issued the transaction, so a backend picks the right API. Added in 0.3.2. */
|
|
105
|
+
platform: 'android' | 'ios';
|
|
91
106
|
}
|
|
92
107
|
|
|
93
108
|
export interface ValidationDecision {
|
package/src/core/timers.ts
CHANGED
|
@@ -34,6 +34,16 @@ export const DEFAULT_SYNC_TIMEOUT_MS = 20_000;
|
|
|
34
34
|
export const DEFAULT_PURCHASE_LAUNCH_TIMEOUT_MS = 30_000;
|
|
35
35
|
/** Per finalize attempt, not per transaction — the queue owns overall persistence. */
|
|
36
36
|
export const DEFAULT_FINALIZE_ATTEMPT_TIMEOUT_MS = 15_000;
|
|
37
|
+
/** The app's backend hook. A user is waiting on a purchase result behind it. */
|
|
38
|
+
export const DEFAULT_VALIDATE_TIMEOUT_MS = 15_000;
|
|
39
|
+
/**
|
|
40
|
+
* The ceiling on a purchase flow whose outcome includes the user's own time in
|
|
41
|
+
* the store UI, so is otherwise untimed (FR-035). Not a timeout on the user: it
|
|
42
|
+
* exists only so a binding that never answers cannot hold the purchase lock for
|
|
43
|
+
* the rest of the session. A purchase that completes later still arrives through
|
|
44
|
+
* the store update channel and is granted then.
|
|
45
|
+
*/
|
|
46
|
+
export const PURCHASE_FLOW_CEILING_MS = 10 * 60_000;
|
|
37
47
|
|
|
38
48
|
export const CONNECT_TIMEOUT_BOUNDS = { min: 3_000, max: 60_000 } as const;
|
|
39
49
|
export const PRODUCT_QUERY_TIMEOUT_BOUNDS = { min: 3_000, max: 60_000 } as const;
|
|
@@ -46,6 +56,7 @@ export const FINALIZE_ATTEMPT_TIMEOUT_BOUNDS = {
|
|
|
46
56
|
min: 3_000,
|
|
47
57
|
max: 60_000,
|
|
48
58
|
} as const;
|
|
59
|
+
export const VALIDATE_TIMEOUT_BOUNDS = { min: 3_000, max: 120_000 } as const;
|
|
49
60
|
|
|
50
61
|
/**
|
|
51
62
|
* Prices and offer eligibility are shown at the moment of purchase, so a short
|
|
@@ -138,6 +149,7 @@ export interface ResolvedTimeouts {
|
|
|
138
149
|
syncMs: number;
|
|
139
150
|
purchaseLaunchMs: number;
|
|
140
151
|
finalizeAttemptMs: number;
|
|
152
|
+
validateMs: number;
|
|
141
153
|
}
|
|
142
154
|
|
|
143
155
|
export function resolveTimeouts(config?: TimeoutConfig): ResolvedTimeouts {
|
|
@@ -172,6 +184,12 @@ export function resolveTimeouts(config?: TimeoutConfig): ResolvedTimeouts {
|
|
|
172
184
|
DEFAULT_FINALIZE_ATTEMPT_TIMEOUT_MS,
|
|
173
185
|
FINALIZE_ATTEMPT_TIMEOUT_BOUNDS,
|
|
174
186
|
),
|
|
187
|
+
validateMs: requireInBounds(
|
|
188
|
+
'timeouts.validateMs',
|
|
189
|
+
config?.validateMs,
|
|
190
|
+
DEFAULT_VALIDATE_TIMEOUT_MS,
|
|
191
|
+
VALIDATE_TIMEOUT_BOUNDS,
|
|
192
|
+
),
|
|
175
193
|
};
|
|
176
194
|
}
|
|
177
195
|
|
|
@@ -29,6 +29,8 @@ export const PAYMENT_ERROR_CODES = [
|
|
|
29
29
|
'PAYMENT_VALIDATION_REJECTED',
|
|
30
30
|
'PAYMENT_VALIDATION_UNAVAILABLE',
|
|
31
31
|
'PAYMENT_STORAGE_UNAVAILABLE',
|
|
32
|
+
/** `consume` on a transaction that is not a consumable, added in 0.3.2. */
|
|
33
|
+
'PAYMENT_NOT_CONSUMABLE',
|
|
32
34
|
] as const;
|
|
33
35
|
|
|
34
36
|
export type PaymentErrorCode = (typeof PAYMENT_ERROR_CODES)[number];
|
|
@@ -98,13 +100,18 @@ export class PaymentConfigError extends PaymentError {
|
|
|
98
100
|
*
|
|
99
101
|
* Applied at the boundary rather than trusted to call sites: a single forgotten
|
|
100
102
|
* interpolation is how receipts end up in logs (NFR-006).
|
|
103
|
+
*
|
|
104
|
+
* Identifier-only: what survives is what code defined, never what data carried.
|
|
105
|
+
* A `PaymentError` keeps its code and an `Error` its class name; every message
|
|
106
|
+
* and every string is dropped, because a store or consumer message can embed a
|
|
107
|
+
* purchase token or an account id and nothing here can tell which.
|
|
101
108
|
*/
|
|
102
109
|
export function redact(value: unknown): string {
|
|
103
|
-
if (value instanceof
|
|
104
|
-
return value.
|
|
110
|
+
if (value instanceof PaymentError) {
|
|
111
|
+
return value.code;
|
|
105
112
|
}
|
|
106
|
-
if (
|
|
107
|
-
return value;
|
|
113
|
+
if (value instanceof Error) {
|
|
114
|
+
return value.name;
|
|
108
115
|
}
|
|
109
116
|
return '[redacted]';
|
|
110
117
|
}
|