@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 +61 -0
- package/README.md +46 -15
- package/package.json +1 -1
- package/skills/sdk-rn-payment/SKILL.md +4 -4
- package/src/adapter/iap-adapter.ts +12 -216
- package/src/adapter/iapShapes.ts +232 -0
- package/src/config/types.ts +8 -0
- package/src/config/validate.ts +19 -4
- package/src/core/retry.ts +1 -58
- package/src/diagnostics/events.ts +1 -1
- package/src/engine/connection.ts +194 -0
- package/src/engine/engine.ts +140 -700
- package/src/engine/internals.ts +40 -0
- package/src/engine/products.ts +39 -17
- package/src/engine/purchase.ts +22 -130
- package/src/engine/purchaseFailure.ts +48 -0
- package/src/engine/receiptValidation.ts +101 -0
- package/src/engine/state.ts +253 -0
- package/src/engine/sync.ts +161 -0
- package/src/entitlements/aggregate.ts +3 -5
- package/src/entitlements/mapping.ts +0 -22
- package/src/persistence/mmkv-store.ts +10 -3
- package/src/persistence/snapshot.ts +102 -27
- package/src/react/PaymentProvider.tsx +73 -24
- package/src/react/hooks.ts +13 -3
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
|
-
> **
|
|
95
|
-
> `android
|
|
96
|
-
>
|
|
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={
|
|
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
|
-
|
|
|
755
|
-
|
|
|
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. **
|
|
849
|
-
|
|
850
|
-
|
|
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
|
|
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
|
@@ -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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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,
|