@chipmobilesdk/rn-payment 0.2.1 → 0.3.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 +65 -0
- package/README.md +19 -14
- package/package.json +2 -2
- package/skills/sdk-rn-payment/SKILL.md +14 -4
- package/src/adapter/createStoreProvider.ts +1 -0
- package/src/adapter/iap-adapter.ts +166 -57
- package/src/adapter/port.ts +18 -1
- package/src/engine/purchase.ts +58 -32
- package/src/testing/fakeStore.ts +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,71 @@ product keys, error codes, event names, state values, message keys, and the
|
|
|
7
7
|
public API shape are this package's contract: renaming any of them is a breaking
|
|
8
8
|
change.
|
|
9
9
|
|
|
10
|
+
## [0.3.1] — 2026-10-25
|
|
11
|
+
|
|
12
|
+
Patch release. **Upgrade before shipping on Android**: with `0.3.0` no purchase could
|
|
13
|
+
complete on a device. Found on a Galaxy S25 Ultra with a licence-tester account, in the
|
|
14
|
+
first app to buy through this package (Task Manager, internal testing track).
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **Every purchase failed before Play opened.** The adapter sent
|
|
19
|
+
`request: { android: { skus, obfuscatedAccountIdAndroid } }`, the request shape from
|
|
20
|
+
`react-native-iap`'s documentation before 16.7. The 16.7 binding reads
|
|
21
|
+
`request.google` and rejected the call with `empty-sku-list`; the app saw a store
|
|
22
|
+
error. The request now uses `google` and `obfuscatedAccountId`, and is typed with the
|
|
23
|
+
binding's own `RequestPurchaseProps`, so a renamed key fails to typecheck.
|
|
24
|
+
- **A completed purchase would have been reported as a store error.** On Android
|
|
25
|
+
`requestPurchase` resolves with an empty list when the flow ends, whatever happened;
|
|
26
|
+
the outcome arrives only through `purchaseUpdatedListener` / `purchaseErrorListener`.
|
|
27
|
+
The adapter read the resolved value, so it would have reported `store_error` for
|
|
28
|
+
every purchase, while Play delivered the purchase separately. `launchPurchase` now
|
|
29
|
+
settles on the first outcome event for the product (bought, pending, or an error).
|
|
30
|
+
- **"Already owned" was a generic store error.** The adapter matched
|
|
31
|
+
`item-already-owned`; 16.7 sends `already-owned`. A reinstall that taps Buy again
|
|
32
|
+
now gets `already_owned` and the self-repairing sync (FR-033). `sku-not-found` now
|
|
33
|
+
maps to `unknown_product`.
|
|
34
|
+
- **The launch timeout cut into the user's time in Play (FR-035).** Because the
|
|
35
|
+
binding reports nothing until the flow ends, the 30-second "opening the store
|
|
36
|
+
surface" timeout covered the time the user spends choosing a payment method. The
|
|
37
|
+
engine no longer applies it to a provider whose outcome includes that time (the Play
|
|
38
|
+
adapter); the binding always ends the flow with an event, so the call cannot hang.
|
|
39
|
+
- **Plan changes sent the removed 16.6 fields.** A plan change now sends the replaced
|
|
40
|
+
purchase's token as `purchaseToken` and, with a proration mode,
|
|
41
|
+
`subscriptionProductReplacementParams` (old product id + replacement mode).
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- Peer `react-native-iap` is now `>=16.7.3 <17`, the version this adapter was verified
|
|
46
|
+
against (was `>=16.6.1 <17`, never verified).
|
|
47
|
+
- Internal: `ProviderPurchaseRequest.replaceStoreProductId` now holds the replaced
|
|
48
|
+
product's id and the token moved to `replacePurchaseToken` (it held the token before);
|
|
49
|
+
`ProviderCapabilities` gained `purchaseOutcomeIncludesStoreTime`. Neither type is
|
|
50
|
+
public API. The fake store keeps `false`, so launch-timeout tests behave as before.
|
|
51
|
+
|
|
52
|
+
### Added
|
|
53
|
+
|
|
54
|
+
- `__tests__/iap-adapter.test.ts`: the adapter against a fake binding that validates
|
|
55
|
+
and resolves as 16.7.3 does — request shape, event-based outcomes, error mapping, the
|
|
56
|
+
no-event fallback.
|
|
57
|
+
|
|
58
|
+
## [0.3.0] — 2026-10-13
|
|
59
|
+
|
|
60
|
+
Documentation release: the README and the agent skill now match the code `0.2.1` shipped.
|
|
61
|
+
**No code, API, storage or behaviour change** — upgrading from `0.2.1` changes nothing at
|
|
62
|
+
runtime. Released as its own version so the skill a consuming app syncs (`packageVersion`)
|
|
63
|
+
always matches the package it came from.
|
|
64
|
+
|
|
65
|
+
### Changed
|
|
66
|
+
|
|
67
|
+
- README `### Migrating 0.1.0 to 0.2.0`: no longer claims `0.1.0` persisted pending records.
|
|
68
|
+
On a device nothing persisted before `0.2.1`; the section now says to use `0.2.1` or
|
|
69
|
+
later and what a device first running it does (empty record, `unknown` until the first
|
|
70
|
+
sync, never a false `not_entitled`).
|
|
71
|
+
- README intro: "Android only for now" instead of a version-pinned phrase.
|
|
72
|
+
- Skill: "Require `0.2.1` or later" under the upgrade notes, with the reason; the public
|
|
73
|
+
API heading names `v0.3.0` (the surface is unchanged since `0.2.0`).
|
|
74
|
+
|
|
10
75
|
## [0.2.1] — 2026-10-12
|
|
11
76
|
|
|
12
77
|
Patch release. **Upgrade before shipping**: on a device, `0.2.0` and earlier persisted
|
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @chipmobilesdk/rn-payment
|
|
2
2
|
|
|
3
3
|
Reusable in-app payment and entitlement capability for ChipMobileSdk-family React
|
|
4
|
-
Native apps. Android only
|
|
4
|
+
Native apps. Android only for now, via Google Play Billing, behind a contract
|
|
5
5
|
that is neutral with respect to platform and store.
|
|
6
6
|
|
|
7
7
|
**The package owns capability. Your app owns policy.** The package gives you a
|
|
@@ -112,7 +112,7 @@ Verified against this repository's toolchain, not assumed:
|
|
|
112
112
|
| Node | >= 22.11.0 |
|
|
113
113
|
| Android `minSdk` / `compileSdk` / `targetSdk` | 24 / 36 / 36 |
|
|
114
114
|
| Kotlin | 2.1.20 |
|
|
115
|
-
| `react-native-iap` | >= 16.
|
|
115
|
+
| `react-native-iap` | >= 16.7.3 < 17 (verified 16.7.3) |
|
|
116
116
|
| `react-native-mmkv` | >= 4.0.0 |
|
|
117
117
|
| `react-native-nitro-modules` | >= 0.36.5 |
|
|
118
118
|
|
|
@@ -120,16 +120,18 @@ Verified against this repository's toolchain, not assumed:
|
|
|
120
120
|
requirement and the bundled Play Billing version move on majors, so a major bump
|
|
121
121
|
needs re-verification rather than silent adoption.
|
|
122
122
|
|
|
123
|
-
> ⚠️ **
|
|
124
|
-
>
|
|
125
|
-
>
|
|
126
|
-
>
|
|
127
|
-
>
|
|
123
|
+
> ⚠️ **Use `0.3.1` or later.** Up to `0.3.0` the Android adapter followed
|
|
124
|
+
> `react-native-iap` 16.x as documented, not as installed, and on a device **no
|
|
125
|
+
> purchase could complete**: the request used the pre-16.7 `android` key, which the
|
|
126
|
+
> binding rejects before Play opens. `0.3.1` was checked call by call against the
|
|
127
|
+
> installed 16.7.3 source, types its request with the binding's own
|
|
128
|
+
> `RequestPurchaseProps` (a renamed key now fails to typecheck), and is tested
|
|
129
|
+
> against a fake binding that behaves as 16.7.3 does. Still confirm one licence-tester
|
|
130
|
+
> purchase on a device before shipping.
|
|
128
131
|
>
|
|
129
|
-
>
|
|
130
|
-
>
|
|
131
|
-
>
|
|
132
|
-
> in `0.2.0`; see the changelog.
|
|
132
|
+
> Earlier, the same check of how a purchase's state is read found a defect in `0.1.0`
|
|
133
|
+
> — the binding sends the state as a string, the adapter expected a number, and a
|
|
134
|
+
> pending purchase was granted. Fixed in `0.2.0`; see the changelog.
|
|
133
135
|
|
|
134
136
|
---
|
|
135
137
|
|
|
@@ -874,9 +876,12 @@ Everything else is additive, and `0.1.0` call sites compile and behave unchanged
|
|
|
874
876
|
A hand-written `PaymentEngine` implementation, typically a test mock, needs the two
|
|
875
877
|
new methods.
|
|
876
878
|
|
|
877
|
-
**No storage migration.** The schema stays at version 1
|
|
878
|
-
|
|
879
|
-
|
|
879
|
+
**No storage migration.** The schema stays at version 1, and `0.2.0` reads the
|
|
880
|
+
pending records the engine already wrote. **Use `0.2.1` or later**, though: before
|
|
881
|
+
`0.2.1` the store fell back to memory on every device (react-native-mmkv v4 has no
|
|
882
|
+
`MMKV` constructor), so nothing — entitlements included — survived a cold start. A
|
|
883
|
+
device first running `0.2.1` starts from an empty record and rebuilds it at the first
|
|
884
|
+
sync, with `unknown` meanwhile, never a false `not_entitled`.
|
|
880
885
|
|
|
881
886
|
**Replacing an app-side workaround.** If you kept your own "pending since" marker
|
|
882
887
|
because `0.1.0` could not show a pending payment in a later session, drop it for
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chipmobilesdk/rn-payment",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"description": "Reusable in-app payment and entitlement capability for ChipMobileSdk React Native apps",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"peerDependencies": {
|
|
42
42
|
"react": ">=19",
|
|
43
43
|
"react-native": ">=0.85",
|
|
44
|
-
"react-native-iap": ">=16.
|
|
44
|
+
"react-native-iap": ">=16.7.3 <17",
|
|
45
45
|
"react-native-mmkv": ">=4.0.0",
|
|
46
46
|
"react-native-nitro-modules": ">=0.36.5"
|
|
47
47
|
},
|
|
@@ -3,7 +3,7 @@ name: sdk-rn-payment
|
|
|
3
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
4
|
metadata:
|
|
5
5
|
package: "@chipmobilesdk/rn-payment"
|
|
6
|
-
packageVersion: "0.
|
|
6
|
+
packageVersion: "0.3.1"
|
|
7
7
|
user-invocable: true
|
|
8
8
|
disable-model-invocation: false
|
|
9
9
|
---
|
|
@@ -93,7 +93,7 @@ Unlike `@chipmobilesdk/rn-ads`, there is **no test-id substitution**: Play has n
|
|
|
93
93
|
Call `handleForeground()` yourself on app resume — the package will not guess what
|
|
94
94
|
"foreground" means for a host backgrounded by its own modal or the store's purchase sheet.
|
|
95
95
|
|
|
96
|
-
## Public API (v0.
|
|
96
|
+
## Public API (v0.3.1)
|
|
97
97
|
|
|
98
98
|
Runtime: `createEngine` (throws `PaymentConfigError` on a bad catalog or an unsafe
|
|
99
99
|
environment; everything else returns a typed result) · `createStoreProvider` ·
|
|
@@ -131,8 +131,18 @@ Types: `PaymentConfig`, `CatalogEntry`, `ProductType`, `StoreProductIds`,
|
|
|
131
131
|
|
|
132
132
|
One compile-time change: `PaymentEventType` gained `'purchase_pending_resolved'`, so an
|
|
133
133
|
exhaustive `switch` needs the case or a `default`. A hand-written `PaymentEngine` mock
|
|
134
|
-
needs the two pending methods. No storage migration
|
|
135
|
-
|
|
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.
|
|
136
146
|
|
|
137
147
|
**Upgrade before shipping on Android.** In `0.1.0` the adapter misread `react-native-iap`
|
|
138
148
|
16.x's string purchase state, so on a device a pending purchase was granted. `0.2.0`
|
|
@@ -33,6 +33,7 @@ const UNSUPPORTED_CAPABILITIES: ProviderCapabilities = {
|
|
|
33
33
|
supportsExternalTransactions: false,
|
|
34
34
|
/** Nothing here can charge anyone, so the environment guard has nothing to block. */
|
|
35
35
|
createsRealTransactions: false,
|
|
36
|
+
purchaseOutcomeIncludesStoreTime: false,
|
|
36
37
|
};
|
|
37
38
|
|
|
38
39
|
/**
|
|
@@ -6,19 +6,24 @@
|
|
|
6
6
|
* and lets an iOS adapter be added without changing a consumer call (FR-002,
|
|
7
7
|
* FR-007). ESLint enforces that boundary.
|
|
8
8
|
*
|
|
9
|
-
* ##
|
|
9
|
+
* ## Verified against react-native-iap 16.7.3
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* `react-native-iap` 16.x as documented, and **every one must be verified against
|
|
15
|
-
* the installed package** before this adapter is trusted on a device. The module
|
|
16
|
-
* is required lazily and the surface it needs is declared explicitly in
|
|
17
|
-
* `IapModule`, so a mismatch surfaces here rather than deep in the engine.
|
|
11
|
+
* 0.3.0 was written before the binding was installed, from its documentation, and
|
|
12
|
+
* no purchase could complete on a device. Checked against the installed 16.7.3
|
|
13
|
+
* source for 0.3.1, two facts shape the purchase path:
|
|
18
14
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
15
|
+
* - The request is keyed by store: `google` / `apple`. 0.3.0 sent `android`, which
|
|
16
|
+
* the binding rejects (`empty-sku-list`) before Play opens. The request type is
|
|
17
|
+
* now the binding's own `RequestPurchaseProps`, so a renamed key fails to
|
|
18
|
+
* typecheck here instead of failing on a user's device.
|
|
19
|
+
* - On Android `requestPurchase` resolves with an **empty list** once the flow
|
|
20
|
+
* ends, whatever happened. The outcome (bought, pending, cancelled, failed)
|
|
21
|
+
* arrives only through `purchaseUpdatedListener` / `purchaseErrorListener`, so
|
|
22
|
+
* `launchPurchase` waits for those events.
|
|
23
|
+
*
|
|
24
|
+
* The module is still required lazily, and `IapModule` still names exactly the
|
|
25
|
+
* surface used. `__tests__/iap-adapter.test.ts` drives this file through a fake
|
|
26
|
+
* binding that behaves as 16.7.3 does; the rest of the suite uses the fake store.
|
|
22
27
|
*
|
|
23
28
|
* ## Known upstream defect this guards against
|
|
24
29
|
*
|
|
@@ -31,6 +36,11 @@
|
|
|
31
36
|
* shortcut.
|
|
32
37
|
*/
|
|
33
38
|
|
|
39
|
+
import type {
|
|
40
|
+
RequestPurchaseProps,
|
|
41
|
+
SubscriptionReplacementModeAndroid,
|
|
42
|
+
} from 'react-native-iap';
|
|
43
|
+
|
|
34
44
|
import type { ProductType, Unsubscribe } from '../config/types';
|
|
35
45
|
import type {
|
|
36
46
|
ProviderCapabilities,
|
|
@@ -61,7 +71,8 @@ interface IapModule {
|
|
|
61
71
|
type: 'in-app' | 'subs';
|
|
62
72
|
}): Promise<RawProduct[]>;
|
|
63
73
|
getAvailablePurchases(): Promise<RawPurchase[]>;
|
|
64
|
-
|
|
74
|
+
/** Typed by the binding itself, so a renamed request key fails to compile. */
|
|
75
|
+
requestPurchase(params: RequestPurchaseProps): Promise<unknown>;
|
|
65
76
|
finishTransaction(params: {
|
|
66
77
|
purchase: unknown;
|
|
67
78
|
isConsumable?: boolean;
|
|
@@ -69,9 +80,9 @@ interface IapModule {
|
|
|
69
80
|
purchaseUpdatedListener(
|
|
70
81
|
listener: (purchase: RawPurchase) => void,
|
|
71
82
|
): { remove(): void };
|
|
72
|
-
purchaseErrorListener(
|
|
73
|
-
|
|
74
|
-
|
|
83
|
+
purchaseErrorListener(listener: (error: RawPurchaseError) => void): {
|
|
84
|
+
remove(): void;
|
|
85
|
+
};
|
|
75
86
|
deepLinkToSubscriptions?(params: { skuAndroid?: string }): Promise<void>;
|
|
76
87
|
}
|
|
77
88
|
|
|
@@ -129,6 +140,75 @@ interface RawPurchase {
|
|
|
129
140
|
dataAndroid?: string;
|
|
130
141
|
}
|
|
131
142
|
|
|
143
|
+
/** What `purchaseErrorListener` delivers. */
|
|
144
|
+
interface RawPurchaseError {
|
|
145
|
+
code?: string;
|
|
146
|
+
message?: string;
|
|
147
|
+
productId?: string | null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* How long to wait for the outcome event after `requestPurchase` has resolved.
|
|
152
|
+
* The binding emits the event before or as it resolves, so this only bounds the
|
|
153
|
+
* case where none ever comes; it never runs while the user is in Play's UI.
|
|
154
|
+
*/
|
|
155
|
+
const RESULT_EVENT_GRACE_MS = 2_000;
|
|
156
|
+
|
|
157
|
+
/** Play's replacement modes, by the codes the engine passes (`engine/subscriptions.ts`). */
|
|
158
|
+
const REPLACEMENT_MODES: Record<string, SubscriptionReplacementModeAndroid> = {
|
|
159
|
+
WITH_TIME_PRORATION: 'with-time-proration',
|
|
160
|
+
CHARGE_FULL_PRICE: 'charge-full-price',
|
|
161
|
+
CHARGE_PRORATED_PRICE: 'charge-prorated-price',
|
|
162
|
+
DEFERRED: 'deferred',
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** The request in the binding's 16.7 shape: keyed `google`, not `android`. */
|
|
166
|
+
export function toRequestProps(request: ProviderPurchaseRequest): RequestPurchaseProps {
|
|
167
|
+
const google = {
|
|
168
|
+
skus: [request.storeProductId],
|
|
169
|
+
...(request.obfuscatedAccountId
|
|
170
|
+
? { obfuscatedAccountId: request.obfuscatedAccountId }
|
|
171
|
+
: {}),
|
|
172
|
+
};
|
|
173
|
+
if (request.productType !== 'subscription') {
|
|
174
|
+
return { request: { google }, type: 'in-app' };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const replacementMode = request.prorationMode
|
|
178
|
+
? REPLACEMENT_MODES[request.prorationMode]
|
|
179
|
+
: undefined;
|
|
180
|
+
return {
|
|
181
|
+
request: {
|
|
182
|
+
google: {
|
|
183
|
+
...google,
|
|
184
|
+
...(request.offerKey
|
|
185
|
+
? {
|
|
186
|
+
subscriptionOffers: [
|
|
187
|
+
{ sku: request.storeProductId, offerToken: request.offerKey },
|
|
188
|
+
],
|
|
189
|
+
}
|
|
190
|
+
: {}),
|
|
191
|
+
// A plan change: the token of the purchase being replaced, and — for
|
|
192
|
+
// the replacement mode to apply — its product id (Billing 8.1+).
|
|
193
|
+
...(request.replacePurchaseToken
|
|
194
|
+
? { purchaseToken: request.replacePurchaseToken }
|
|
195
|
+
: {}),
|
|
196
|
+
...(request.replacePurchaseToken &&
|
|
197
|
+
request.replaceStoreProductId &&
|
|
198
|
+
replacementMode
|
|
199
|
+
? {
|
|
200
|
+
subscriptionProductReplacementParams: {
|
|
201
|
+
oldProductId: request.replaceStoreProductId,
|
|
202
|
+
replacementMode,
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
: {}),
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
type: 'subs',
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
132
212
|
/** Play's `PurchaseState`: 0 unspecified, 1 purchased, 2 pending. */
|
|
133
213
|
const PLAY_STATE_PURCHASED = 1;
|
|
134
214
|
const PLAY_STATE_PENDING = 2;
|
|
@@ -272,10 +352,12 @@ function mapErrorCode(code: string | undefined): ProviderFailureReason {
|
|
|
272
352
|
case 'user-cancelled':
|
|
273
353
|
return 'user_cancelled';
|
|
274
354
|
case 'E_ALREADY_OWNED':
|
|
355
|
+
case 'already-owned':
|
|
275
356
|
case 'item-already-owned':
|
|
276
357
|
return 'already_owned';
|
|
277
358
|
case 'E_ITEM_UNAVAILABLE':
|
|
278
359
|
case 'item-unavailable':
|
|
360
|
+
case 'sku-not-found':
|
|
279
361
|
return 'unknown_product';
|
|
280
362
|
case 'E_NOT_PREPARED':
|
|
281
363
|
case 'not-prepared':
|
|
@@ -309,6 +391,8 @@ const ANDROID_CAPABILITIES: ProviderCapabilities = {
|
|
|
309
391
|
supportsExternalTransactions: false,
|
|
310
392
|
/** This one really does move money, which is what arms the environment guard. */
|
|
311
393
|
createsRealTransactions: true,
|
|
394
|
+
/** The outcome event comes only when the user leaves Play's purchase UI. */
|
|
395
|
+
purchaseOutcomeIncludesStoreTime: true,
|
|
312
396
|
};
|
|
313
397
|
|
|
314
398
|
export interface IapAdapterOptions {
|
|
@@ -326,6 +410,8 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
|
|
|
326
410
|
(connected: boolean, reason?: string) => void
|
|
327
411
|
>();
|
|
328
412
|
const purchaseListeners = new Set<(purchase: ProviderPurchase) => void>();
|
|
413
|
+
/** Only an in-flight `launchPurchase` listens here; see there. */
|
|
414
|
+
const purchaseErrorListeners = new Set<(error: RawPurchaseError) => void>();
|
|
329
415
|
let updateSub: { remove(): void } | undefined;
|
|
330
416
|
let errorSub: { remove(): void } | undefined;
|
|
331
417
|
|
|
@@ -361,6 +447,9 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
|
|
|
361
447
|
if (mapErrorCode(error.code) === 'not_ready') {
|
|
362
448
|
notifyConnection(false, 'not_prepared');
|
|
363
449
|
}
|
|
450
|
+
for (const listener of purchaseErrorListeners) {
|
|
451
|
+
listener(error);
|
|
452
|
+
}
|
|
364
453
|
});
|
|
365
454
|
};
|
|
366
455
|
|
|
@@ -450,49 +539,69 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
|
|
|
450
539
|
return raw.map(normalizePurchase);
|
|
451
540
|
},
|
|
452
541
|
|
|
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
|
-
|
|
542
|
+
/**
|
|
543
|
+
* Settles on the first outcome event for this product: a purchase update
|
|
544
|
+
* (bought or pending) or a purchase error (cancelled, already owned, …).
|
|
545
|
+
*
|
|
546
|
+
* The binding's promise is not the outcome: on Android it resolves with an
|
|
547
|
+
* empty list when the flow ends, whatever happened. Its listeners also feed
|
|
548
|
+
* `observePurchases`, so the engine sees a bought purchase twice — here and
|
|
549
|
+
* as a store update — and grants it once (FR-044).
|
|
550
|
+
*/
|
|
551
|
+
launchPurchase(request: ProviderPurchaseRequest): Promise<ProviderPurchaseOutcome> {
|
|
552
|
+
const sku = request.storeProductId;
|
|
553
|
+
return new Promise<ProviderPurchaseOutcome>(resolve => {
|
|
554
|
+
let settled = false;
|
|
555
|
+
let grace: ReturnType<typeof setTimeout> | undefined;
|
|
556
|
+
|
|
557
|
+
const onPurchase = (purchase: ProviderPurchase): void => {
|
|
558
|
+
if (purchase.storeProductId === sku) {
|
|
559
|
+
finish({ ok: true, purchase });
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
const onError = (error: RawPurchaseError): void => {
|
|
563
|
+
if (error.productId && error.productId !== sku) {
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
finish({ ok: false, reason: mapErrorCode(error.code), detail: error.code });
|
|
567
|
+
};
|
|
568
|
+
const fail = (error: unknown): void => {
|
|
569
|
+
const code = (error as { code?: string } | undefined)?.code;
|
|
570
|
+
finish({ ok: false, reason: mapErrorCode(code), detail: code });
|
|
571
|
+
};
|
|
572
|
+
function finish(outcome: ProviderPurchaseOutcome): void {
|
|
573
|
+
if (settled) {
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
settled = true;
|
|
577
|
+
clearTimeout(grace);
|
|
578
|
+
purchaseListeners.delete(onPurchase);
|
|
579
|
+
purchaseErrorListeners.delete(onError);
|
|
580
|
+
resolve(outcome);
|
|
490
581
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
582
|
+
|
|
583
|
+
purchaseListeners.add(onPurchase);
|
|
584
|
+
purchaseErrorListeners.add(onError);
|
|
585
|
+
try {
|
|
586
|
+
module()
|
|
587
|
+
.requestPurchase(toRequestProps(request))
|
|
588
|
+
.then(() => {
|
|
589
|
+
if (!settled) {
|
|
590
|
+
grace = setTimeout(
|
|
591
|
+
() =>
|
|
592
|
+
finish({
|
|
593
|
+
ok: false,
|
|
594
|
+
reason: 'store_error',
|
|
595
|
+
detail: 'no purchase event',
|
|
596
|
+
}),
|
|
597
|
+
RESULT_EVENT_GRACE_MS,
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
}, fail);
|
|
601
|
+
} catch (error) {
|
|
602
|
+
fail(error);
|
|
603
|
+
}
|
|
604
|
+
});
|
|
496
605
|
},
|
|
497
606
|
|
|
498
607
|
observePurchases(listener): Unsubscribe {
|
package/src/adapter/port.ts
CHANGED
|
@@ -102,7 +102,12 @@ export interface ProviderPurchaseRequest {
|
|
|
102
102
|
offerKey?: string;
|
|
103
103
|
/** App-supplied, passed straight through. Never generated or stored (FR-034). */
|
|
104
104
|
obfuscatedAccountId?: string;
|
|
105
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* Plan change: the token of the active subscription being replaced. The store
|
|
107
|
+
* migrates *that* purchase rather than creating a second one (FR-065).
|
|
108
|
+
*/
|
|
109
|
+
replacePurchaseToken?: ProviderToken;
|
|
110
|
+
/** Plan change: the store product id of the subscription being replaced. */
|
|
106
111
|
replaceStoreProductId?: StoreProductId;
|
|
107
112
|
prorationMode?: string;
|
|
108
113
|
}
|
|
@@ -138,6 +143,18 @@ export interface ProviderCapabilities {
|
|
|
138
143
|
* a development build.
|
|
139
144
|
*/
|
|
140
145
|
createsRealTransactions: boolean;
|
|
146
|
+
/**
|
|
147
|
+
* Whether `launchPurchase` settles only once the user has left the store's
|
|
148
|
+
* purchase surface.
|
|
149
|
+
*
|
|
150
|
+
* `true` for the Play adapter: the binding gives no "surface opened" signal and
|
|
151
|
+
* reports the outcome only when the flow ends, so the time the user spends in
|
|
152
|
+
* Play is inside the call. The engine then does not apply its launch timeout,
|
|
153
|
+
* which would otherwise cancel a user still choosing a payment method (FR-035).
|
|
154
|
+
* The binding always ends the flow with a result or an error, so the call
|
|
155
|
+
* cannot hang. `false` for the fake store, whose launch returns at once.
|
|
156
|
+
*/
|
|
157
|
+
purchaseOutcomeIncludesStoreTime: boolean;
|
|
141
158
|
}
|
|
142
159
|
|
|
143
160
|
export interface ProviderPort {
|
package/src/engine/purchase.ts
CHANGED
|
@@ -28,6 +28,9 @@ import type {
|
|
|
28
28
|
ProviderFailureReason,
|
|
29
29
|
ProviderPort,
|
|
30
30
|
ProviderPurchase,
|
|
31
|
+
ProviderPurchaseOutcome,
|
|
32
|
+
ProviderPurchaseRequest,
|
|
33
|
+
StoreProductId,
|
|
31
34
|
} from '../adapter/port';
|
|
32
35
|
import type { ExclusiveOutcome } from '../core/mutex';
|
|
33
36
|
import { withTimeout } from '../core/mutex';
|
|
@@ -291,6 +294,60 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
|
291
294
|
return { kind: 'store_error', productKey, code: 'PAYMENT_STORE_ERROR' };
|
|
292
295
|
};
|
|
293
296
|
|
|
297
|
+
/** What the store is asked for: the product and, for a plan change, what it replaces. */
|
|
298
|
+
const launchRequest = (
|
|
299
|
+
productKey: ProductKey,
|
|
300
|
+
storeProductId: StoreProductId,
|
|
301
|
+
options?: InternalPurchaseOptions,
|
|
302
|
+
): ProviderPurchaseRequest => {
|
|
303
|
+
const replaceKey = options?.replaceProductKey;
|
|
304
|
+
return {
|
|
305
|
+
storeProductId,
|
|
306
|
+
productType: resolved.entries.get(productKey)?.productType ?? 'one_time_permanent',
|
|
307
|
+
offerKey: options?.offerKey,
|
|
308
|
+
obfuscatedAccountId: options?.obfuscatedAccountId,
|
|
309
|
+
// Present only for a plan change. The store needs the token of the
|
|
310
|
+
// subscription being replaced so it migrates the existing purchase instead
|
|
311
|
+
// of creating a second one (FR-065), and its product id to apply the
|
|
312
|
+
// replacement mode.
|
|
313
|
+
replacePurchaseToken: replaceKey
|
|
314
|
+
? deps
|
|
315
|
+
.snapshot()
|
|
316
|
+
.purchases.find(p => p.productKey === replaceKey && p.state === 'purchased')
|
|
317
|
+
?.token
|
|
318
|
+
: undefined,
|
|
319
|
+
replaceStoreProductId: replaceKey
|
|
320
|
+
? storeIdForProductKey(resolved, replaceKey)
|
|
321
|
+
: undefined,
|
|
322
|
+
prorationMode: options?.prorationMode,
|
|
323
|
+
};
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The launch timeout covers opening the store surface only; time the user
|
|
328
|
+
* spends inside it is the store's, not ours (FR-035). A provider that reports
|
|
329
|
+
* nothing until the flow ends gives no "opened" moment to time, so it is not
|
|
330
|
+
* timed at all: the timeout would cut into the user's time, and that provider
|
|
331
|
+
* always ends the flow with a result.
|
|
332
|
+
*/
|
|
333
|
+
const launch = (request: ProviderPurchaseRequest): Promise<ProviderPurchaseOutcome> => {
|
|
334
|
+
const launched = deps.provider.launchPurchase(request);
|
|
335
|
+
if (deps.provider.capabilities().purchaseOutcomeIncludesStoreTime) {
|
|
336
|
+
return launched;
|
|
337
|
+
}
|
|
338
|
+
return withTimeout(
|
|
339
|
+
launched,
|
|
340
|
+
resolved.timeouts.purchaseLaunchMs,
|
|
341
|
+
() =>
|
|
342
|
+
new PaymentError({
|
|
343
|
+
code: 'PAYMENT_TIMEOUT',
|
|
344
|
+
message: 'Opening the purchase surface timed out',
|
|
345
|
+
operation: 'purchase',
|
|
346
|
+
retryable: true,
|
|
347
|
+
}),
|
|
348
|
+
);
|
|
349
|
+
};
|
|
350
|
+
|
|
294
351
|
return {
|
|
295
352
|
async purchase(
|
|
296
353
|
productKey: ProductKey,
|
|
@@ -325,7 +382,6 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
|
325
382
|
};
|
|
326
383
|
}
|
|
327
384
|
|
|
328
|
-
const entry = resolved.entries.get(productKey);
|
|
329
385
|
const correlationId = deps.nextCorrelationId();
|
|
330
386
|
|
|
331
387
|
// Exclusive, not single-flight: a second purchase is refused rather than
|
|
@@ -335,37 +391,7 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
|
335
391
|
|
|
336
392
|
let outcome;
|
|
337
393
|
try {
|
|
338
|
-
outcome = await
|
|
339
|
-
deps.provider.launchPurchase({
|
|
340
|
-
storeProductId,
|
|
341
|
-
productType: entry?.productType ?? 'one_time_permanent',
|
|
342
|
-
offerKey: options?.offerKey,
|
|
343
|
-
obfuscatedAccountId: options?.obfuscatedAccountId,
|
|
344
|
-
// Present only for a plan change. The store needs the token of
|
|
345
|
-
// the subscription being replaced so it migrates the existing
|
|
346
|
-
// purchase instead of creating a second one (FR-065).
|
|
347
|
-
replaceStoreProductId: options?.replaceProductKey
|
|
348
|
-
? deps
|
|
349
|
-
.snapshot()
|
|
350
|
-
.purchases.find(
|
|
351
|
-
p =>
|
|
352
|
-
p.productKey === options.replaceProductKey &&
|
|
353
|
-
p.state === 'purchased',
|
|
354
|
-
)?.token
|
|
355
|
-
: undefined,
|
|
356
|
-
prorationMode: options?.prorationMode,
|
|
357
|
-
}),
|
|
358
|
-
// Covers opening the store surface only. Time the user spends
|
|
359
|
-
// inside it is the store's, not ours (FR-035).
|
|
360
|
-
resolved.timeouts.purchaseLaunchMs,
|
|
361
|
-
() =>
|
|
362
|
-
new PaymentError({
|
|
363
|
-
code: 'PAYMENT_TIMEOUT',
|
|
364
|
-
message: 'Opening the purchase surface timed out',
|
|
365
|
-
operation: 'purchase',
|
|
366
|
-
retryable: true,
|
|
367
|
-
}),
|
|
368
|
-
);
|
|
394
|
+
outcome = await launch(launchRequest(productKey, storeProductId, options));
|
|
369
395
|
} catch {
|
|
370
396
|
deps.emit({
|
|
371
397
|
type: 'purchase_failed',
|
package/src/testing/fakeStore.ts
CHANGED
|
@@ -98,6 +98,9 @@ const DEFAULT_CAPABILITIES: ProviderCapabilities = {
|
|
|
98
98
|
// lets a test complete a purchase while still blocking the real adapter
|
|
99
99
|
// outside production (FR-014).
|
|
100
100
|
createsRealTransactions: false,
|
|
101
|
+
// Its launch returns at once, so the engine's launch timeout applies and stays
|
|
102
|
+
// testable (FR-035).
|
|
103
|
+
purchaseOutcomeIncludesStoreTime: false,
|
|
101
104
|
};
|
|
102
105
|
|
|
103
106
|
let sequence = 0;
|