@chipmobilesdk/rn-payment 0.0.0-stage → 0.1.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 +112 -0
- package/README.md +760 -2
- package/package.json +54 -6
- package/skills/sdk-rn-payment/SKILL.md +137 -0
- package/src/adapter/createStoreProvider.ts +117 -0
- package/src/adapter/iap-adapter.ts +516 -0
- package/src/adapter/port.ts +186 -0
- package/src/config/types.ts +255 -0
- package/src/config/validate.ts +232 -0
- package/src/core/mutex.ts +128 -0
- package/src/core/retry.ts +133 -0
- package/src/core/timers.ts +249 -0
- package/src/diagnostics/errors.ts +110 -0
- package/src/diagnostics/events.ts +257 -0
- package/src/diagnostics/messageKeys.ts +95 -0
- package/src/engine/engine.ts +854 -0
- package/src/engine/finalize.ts +298 -0
- package/src/engine/products.ts +259 -0
- package/src/engine/purchase.ts +495 -0
- package/src/engine/subscriptions.ts +157 -0
- package/src/entitlements/aggregate.ts +225 -0
- package/src/entitlements/mapping.ts +67 -0
- package/src/entitlements/policy.ts +129 -0
- package/src/index.ts +168 -0
- package/src/persistence/mmkv-store.ts +150 -0
- package/src/persistence/snapshot.ts +247 -0
- package/src/react/PaymentProvider.tsx +79 -0
- package/src/react/hooks.ts +238 -0
- package/src/testing/fakeStore.ts +341 -0
- package/src/testing/index.ts +10 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Android adapter over `react-native-iap` (Play Billing).
|
|
3
|
+
*
|
|
4
|
+
* **The only file in this package that touches the billing library.** Everything
|
|
5
|
+
* above it talks to `ProviderPort`, which is what keeps the binding replaceable
|
|
6
|
+
* and lets an iOS adapter be added without changing a consumer call (FR-002,
|
|
7
|
+
* FR-007). ESLint enforces that boundary.
|
|
8
|
+
*
|
|
9
|
+
* ## Verification status
|
|
10
|
+
*
|
|
11
|
+
* The binding is **not yet installed** — the root `react-native-nitro-modules`
|
|
12
|
+
* bump it requires is held pending approval (see specs/013-rn-payment-sdk/plan.md,
|
|
13
|
+
* Post-Implementation Notes). So the call shapes below are written against
|
|
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.
|
|
18
|
+
*
|
|
19
|
+
* No test exercises this file: the whole suite runs against the fake store, and
|
|
20
|
+
* the IAP test double throws on every call precisely so that reaching this code in
|
|
21
|
+
* a test fails loudly instead of passing against a stub.
|
|
22
|
+
*
|
|
23
|
+
* ## Known upstream defect this guards against
|
|
24
|
+
*
|
|
25
|
+
* openiap#408: the Android bridge did not clear its internal `isInitialized` flag
|
|
26
|
+
* on disconnect, so `connected` stayed true, later calls failed with
|
|
27
|
+
* `E_NOT_PREPARED`, and `reconnect()` reported success without rebuilding
|
|
28
|
+
* anything. This adapter therefore (a) tracks connection state itself rather than
|
|
29
|
+
* trusting the binding's flag, and (b) always performs a full
|
|
30
|
+
* `endConnection` + `initConnection` cycle instead of calling any reconnect
|
|
31
|
+
* shortcut.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type { ProductType, Unsubscribe } from '../config/types';
|
|
35
|
+
import type {
|
|
36
|
+
ProviderCapabilities,
|
|
37
|
+
ProviderConnectOutcome,
|
|
38
|
+
ProviderFailureReason,
|
|
39
|
+
ProviderPort,
|
|
40
|
+
ProviderPriceChangeOutcome,
|
|
41
|
+
ProviderProduct,
|
|
42
|
+
ProviderPurchase,
|
|
43
|
+
ProviderPurchaseOutcome,
|
|
44
|
+
ProviderPurchaseRequest,
|
|
45
|
+
ProviderToken,
|
|
46
|
+
StoreProductId,
|
|
47
|
+
} from './port';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Exactly what this adapter needs from the binding.
|
|
51
|
+
*
|
|
52
|
+
* Declared narrowly on purpose: it documents the coupling, and it means a binding
|
|
53
|
+
* upgrade that changes one of these shapes fails to typecheck here instead of
|
|
54
|
+
* failing silently at runtime on a user's device.
|
|
55
|
+
*/
|
|
56
|
+
interface IapModule {
|
|
57
|
+
initConnection(): Promise<boolean>;
|
|
58
|
+
endConnection(): Promise<void>;
|
|
59
|
+
fetchProducts(params: {
|
|
60
|
+
skus: string[];
|
|
61
|
+
type: 'in-app' | 'subs';
|
|
62
|
+
}): Promise<RawProduct[]>;
|
|
63
|
+
getAvailablePurchases(): Promise<RawPurchase[]>;
|
|
64
|
+
requestPurchase(params: unknown): Promise<unknown>;
|
|
65
|
+
finishTransaction(params: {
|
|
66
|
+
purchase: unknown;
|
|
67
|
+
isConsumable?: boolean;
|
|
68
|
+
}): Promise<unknown>;
|
|
69
|
+
purchaseUpdatedListener(
|
|
70
|
+
listener: (purchase: RawPurchase) => void,
|
|
71
|
+
): { remove(): void };
|
|
72
|
+
purchaseErrorListener(
|
|
73
|
+
listener: (error: { code?: string; message?: string }) => void,
|
|
74
|
+
): { remove(): void };
|
|
75
|
+
deepLinkToSubscriptions?(params: { skuAndroid?: string }): Promise<void>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface RawProduct {
|
|
79
|
+
id?: string;
|
|
80
|
+
productId?: string;
|
|
81
|
+
title?: string;
|
|
82
|
+
name?: string;
|
|
83
|
+
description?: string;
|
|
84
|
+
displayPrice?: string;
|
|
85
|
+
localizedPrice?: string;
|
|
86
|
+
currency?: string;
|
|
87
|
+
currencyCode?: string;
|
|
88
|
+
price?: string | number;
|
|
89
|
+
oneTimePurchaseOfferDetails?: {
|
|
90
|
+
formattedPrice?: string;
|
|
91
|
+
priceAmountMicros?: string | number;
|
|
92
|
+
priceCurrencyCode?: string;
|
|
93
|
+
};
|
|
94
|
+
subscriptionOfferDetails?: RawSubscriptionOffer[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface RawSubscriptionOffer {
|
|
98
|
+
offerToken?: string;
|
|
99
|
+
offerId?: string;
|
|
100
|
+
basePlanId?: string;
|
|
101
|
+
pricingPhases?: {
|
|
102
|
+
pricingPhaseList?: Array<{
|
|
103
|
+
formattedPrice?: string;
|
|
104
|
+
priceAmountMicros?: string | number;
|
|
105
|
+
priceCurrencyCode?: string;
|
|
106
|
+
billingPeriod?: string;
|
|
107
|
+
recurrenceMode?: number;
|
|
108
|
+
billingCycleCount?: number;
|
|
109
|
+
}>;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface RawPurchase {
|
|
114
|
+
id?: string;
|
|
115
|
+
transactionId?: string;
|
|
116
|
+
productId?: string;
|
|
117
|
+
ids?: string[];
|
|
118
|
+
purchaseToken?: string;
|
|
119
|
+
purchaseStateAndroid?: number;
|
|
120
|
+
purchaseState?: number | string;
|
|
121
|
+
transactionDate?: number;
|
|
122
|
+
isAcknowledgedAndroid?: boolean;
|
|
123
|
+
isAcknowledged?: boolean;
|
|
124
|
+
autoRenewingAndroid?: boolean;
|
|
125
|
+
isAutoRenewing?: boolean;
|
|
126
|
+
expirationDateAndroid?: number;
|
|
127
|
+
transactionReceipt?: string;
|
|
128
|
+
dataAndroid?: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Play's `PurchaseState`: 0 unspecified, 1 purchased, 2 pending. */
|
|
132
|
+
const PLAY_STATE_PURCHASED = 1;
|
|
133
|
+
const PLAY_STATE_PENDING = 2;
|
|
134
|
+
|
|
135
|
+
function loadModule(): IapModule {
|
|
136
|
+
// Required lazily so importing the package does not require the native module.
|
|
137
|
+
const mod = require('react-native-iap') as IapModule;
|
|
138
|
+
return mod;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function toMicros(value: string | number | undefined): number {
|
|
142
|
+
if (typeof value === 'number') {
|
|
143
|
+
return value;
|
|
144
|
+
}
|
|
145
|
+
if (typeof value === 'string') {
|
|
146
|
+
const parsed = Number.parseInt(value, 10);
|
|
147
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
148
|
+
}
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function normalizeProduct(
|
|
153
|
+
raw: RawProduct,
|
|
154
|
+
productType: ProductType,
|
|
155
|
+
): ProviderProduct {
|
|
156
|
+
const oneTime = raw.oneTimePurchaseOfferDetails;
|
|
157
|
+
const currencyCode =
|
|
158
|
+
oneTime?.priceCurrencyCode ?? raw.currencyCode ?? raw.currency ?? '';
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
storeProductId: raw.id ?? raw.productId ?? '',
|
|
162
|
+
productType,
|
|
163
|
+
title: raw.title ?? raw.name ?? '',
|
|
164
|
+
description: raw.description ?? '',
|
|
165
|
+
// Store-formatted, never built here: only the store knows the right currency
|
|
166
|
+
// symbol, separator, and placement for the user's locale (FR-028).
|
|
167
|
+
formattedPrice:
|
|
168
|
+
oneTime?.formattedPrice ?? raw.displayPrice ?? raw.localizedPrice ?? '',
|
|
169
|
+
currencyCode,
|
|
170
|
+
priceAmountMicros: toMicros(oneTime?.priceAmountMicros ?? raw.price),
|
|
171
|
+
offers: (raw.subscriptionOfferDetails ?? []).map(offer =>
|
|
172
|
+
normalizeOffer(offer, currencyCode),
|
|
173
|
+
),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function normalizeOffer(
|
|
178
|
+
offer: RawSubscriptionOffer,
|
|
179
|
+
fallbackCurrency: string,
|
|
180
|
+
): ProviderProduct['offers'][number] {
|
|
181
|
+
const phases = offer.pricingPhases?.pricingPhaseList ?? [];
|
|
182
|
+
// The last phase is the recurring price; earlier ones are trial or
|
|
183
|
+
// introductory. Play orders them that way.
|
|
184
|
+
const recurring = phases[phases.length - 1];
|
|
185
|
+
const intro = phases.length > 1 ? phases[0] : undefined;
|
|
186
|
+
const isFreeTrial = intro !== undefined && toMicros(intro.priceAmountMicros) === 0;
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
offerKey: offer.offerToken ?? offer.offerId ?? offer.basePlanId ?? '',
|
|
190
|
+
billingPeriod: recurring?.billingPeriod ?? '',
|
|
191
|
+
formattedPrice: recurring?.formattedPrice ?? '',
|
|
192
|
+
priceAmountMicros: toMicros(recurring?.priceAmountMicros),
|
|
193
|
+
currencyCode: recurring?.priceCurrencyCode ?? fallbackCurrency,
|
|
194
|
+
trialPeriod: isFreeTrial ? intro?.billingPeriod : undefined,
|
|
195
|
+
introductoryPrice:
|
|
196
|
+
intro && !isFreeTrial
|
|
197
|
+
? {
|
|
198
|
+
formattedPrice: intro.formattedPrice ?? '',
|
|
199
|
+
period: intro.billingPeriod ?? '',
|
|
200
|
+
cycles: intro.billingCycleCount ?? 1,
|
|
201
|
+
}
|
|
202
|
+
: undefined,
|
|
203
|
+
// Play decides eligibility by only returning offers the user qualifies for,
|
|
204
|
+
// so forwarding `true` reports the store's determination rather than
|
|
205
|
+
// computing our own (FR-026).
|
|
206
|
+
isEligible: true,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function normalizePurchase(raw: RawPurchase): ProviderPurchase {
|
|
211
|
+
const state = raw.purchaseStateAndroid ?? raw.purchaseState;
|
|
212
|
+
const numericState = typeof state === 'number' ? state : PLAY_STATE_PURCHASED;
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
storeProductId: raw.productId ?? raw.ids?.[0] ?? '',
|
|
216
|
+
transactionId: raw.id ?? raw.transactionId ?? raw.purchaseToken ?? '',
|
|
217
|
+
token: raw.purchaseToken ?? '',
|
|
218
|
+
// Pending is preserved, never flattened to purchased: that distinction is
|
|
219
|
+
// what stops an entitlement being granted for money that has not arrived
|
|
220
|
+
// (FR-037).
|
|
221
|
+
state:
|
|
222
|
+
numericState === PLAY_STATE_PENDING
|
|
223
|
+
? 'pending'
|
|
224
|
+
: numericState === PLAY_STATE_PURCHASED
|
|
225
|
+
? 'purchased'
|
|
226
|
+
: 'unspecified',
|
|
227
|
+
purchasedAt: raw.transactionDate ?? Date.now(),
|
|
228
|
+
isAcknowledged: raw.isAcknowledgedAndroid ?? raw.isAcknowledged ?? false,
|
|
229
|
+
expiresAt: raw.expirationDateAndroid,
|
|
230
|
+
willAutoRenew: raw.autoRenewingAndroid ?? raw.isAutoRenewing,
|
|
231
|
+
// Opaque material for the app's backend. Carried here and handed to the
|
|
232
|
+
// validation hook only — never logged or put in an event (FR-074).
|
|
233
|
+
verificationPayload: raw.transactionReceipt ?? raw.dataAndroid,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Maps a binding error code onto the port's vocabulary, so no store code escapes. */
|
|
238
|
+
function mapErrorCode(code: string | undefined): ProviderFailureReason {
|
|
239
|
+
switch (code) {
|
|
240
|
+
case 'E_USER_CANCELLED':
|
|
241
|
+
case 'user-cancelled':
|
|
242
|
+
return 'user_cancelled';
|
|
243
|
+
case 'E_ALREADY_OWNED':
|
|
244
|
+
case 'item-already-owned':
|
|
245
|
+
return 'already_owned';
|
|
246
|
+
case 'E_ITEM_UNAVAILABLE':
|
|
247
|
+
case 'item-unavailable':
|
|
248
|
+
return 'unknown_product';
|
|
249
|
+
case 'E_NOT_PREPARED':
|
|
250
|
+
case 'not-prepared':
|
|
251
|
+
return 'not_ready';
|
|
252
|
+
case 'E_NETWORK_ERROR':
|
|
253
|
+
case 'network-error':
|
|
254
|
+
return 'network_error';
|
|
255
|
+
case 'E_DEFERRED_PAYMENT':
|
|
256
|
+
// Not a failure — a pending purchase. The caller resolves it from the
|
|
257
|
+
// purchase update rather than from this error.
|
|
258
|
+
return 'store_error';
|
|
259
|
+
default:
|
|
260
|
+
return 'store_error';
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const ANDROID_CAPABILITIES: ProviderCapabilities = {
|
|
265
|
+
supportsSubscriptions: true,
|
|
266
|
+
supportsPlanChange: true,
|
|
267
|
+
supportsPriceChangeConfirmation: false,
|
|
268
|
+
/**
|
|
269
|
+
* False, and this is a platform fact rather than a gap in this adapter.
|
|
270
|
+
*
|
|
271
|
+
* Client-side Play Billing cannot separate grace period from account hold: a
|
|
272
|
+
* grace-period subscription is still returned as active, and one in account
|
|
273
|
+
* hold stops being returned at all — indistinguishable from expired, cancelled,
|
|
274
|
+
* or refunded. The entitlement layer reads this flag and reports
|
|
275
|
+
* `indeterminate` rather than inventing a distinction (research.md R5).
|
|
276
|
+
*/
|
|
277
|
+
reportsGraceAndHoldDistinctly: false,
|
|
278
|
+
supportsExternalTransactions: false,
|
|
279
|
+
/** This one really does move money, which is what arms the environment guard. */
|
|
280
|
+
createsRealTransactions: true,
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
export interface IapAdapterOptions {
|
|
284
|
+
/** Injected in tests that need to drive the mapping without the native module. */
|
|
285
|
+
module?: IapModule;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort {
|
|
289
|
+
let iap: IapModule | undefined = options.module;
|
|
290
|
+
|
|
291
|
+
/** Tracked here, never read from the binding — see the openiap#408 note above. */
|
|
292
|
+
let connected = false;
|
|
293
|
+
|
|
294
|
+
const connectionListeners = new Set<
|
|
295
|
+
(connected: boolean, reason?: string) => void
|
|
296
|
+
>();
|
|
297
|
+
const purchaseListeners = new Set<(purchase: ProviderPurchase) => void>();
|
|
298
|
+
let updateSub: { remove(): void } | undefined;
|
|
299
|
+
let errorSub: { remove(): void } | undefined;
|
|
300
|
+
|
|
301
|
+
const module = (): IapModule => {
|
|
302
|
+
if (!iap) {
|
|
303
|
+
iap = loadModule();
|
|
304
|
+
}
|
|
305
|
+
return iap;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const notifyConnection = (next: boolean, reason?: string): void => {
|
|
309
|
+
if (connected === next) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
connected = next;
|
|
313
|
+
for (const listener of connectionListeners) {
|
|
314
|
+
listener(next, reason);
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const attachListeners = (): void => {
|
|
319
|
+
const mod = module();
|
|
320
|
+
updateSub = mod.purchaseUpdatedListener(raw => {
|
|
321
|
+
const purchase = normalizePurchase(raw);
|
|
322
|
+
for (const listener of purchaseListeners) {
|
|
323
|
+
listener(purchase);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
errorSub = mod.purchaseErrorListener(error => {
|
|
327
|
+
// A `not-prepared` error is the one signal the binding gives that the
|
|
328
|
+
// service went away underneath us, so it is treated as a disconnect
|
|
329
|
+
// rather than as a purchase failure.
|
|
330
|
+
if (mapErrorCode(error.code) === 'not_ready') {
|
|
331
|
+
notifyConnection(false, 'not_prepared');
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const detachListeners = (): void => {
|
|
337
|
+
updateSub?.remove();
|
|
338
|
+
updateSub = undefined;
|
|
339
|
+
errorSub?.remove();
|
|
340
|
+
errorSub = undefined;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
async connect(): Promise<ProviderConnectOutcome> {
|
|
345
|
+
try {
|
|
346
|
+
const mod = module();
|
|
347
|
+
// Always a full cycle. The binding's own reconnect has reported success
|
|
348
|
+
// without rebuilding anything, so we never rely on it (openiap#408).
|
|
349
|
+
if (connected) {
|
|
350
|
+
await mod.endConnection().catch(() => undefined);
|
|
351
|
+
connected = false;
|
|
352
|
+
}
|
|
353
|
+
const ok = await mod.initConnection();
|
|
354
|
+
if (!ok) {
|
|
355
|
+
return {
|
|
356
|
+
ok: false,
|
|
357
|
+
reason: 'no_billing_service',
|
|
358
|
+
retryable: true,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
attachListeners();
|
|
362
|
+
notifyConnection(true);
|
|
363
|
+
return { ok: true };
|
|
364
|
+
} catch (error) {
|
|
365
|
+
const code = (error as { code?: string }).code;
|
|
366
|
+
// A missing or too-old Play Services install is not the same as a
|
|
367
|
+
// transient error, and the app shows different things for each (FR-021).
|
|
368
|
+
if (code === 'E_IAP_NOT_AVAILABLE' || code === 'iap-not-available') {
|
|
369
|
+
return { ok: false, reason: 'no_billing_service', retryable: false };
|
|
370
|
+
}
|
|
371
|
+
if (code === 'E_SERVICE_ERROR') {
|
|
372
|
+
return {
|
|
373
|
+
ok: false,
|
|
374
|
+
reason: 'billing_service_outdated',
|
|
375
|
+
retryable: true,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
return { ok: false, reason: 'store_error', retryable: true };
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
|
|
382
|
+
async disconnect(): Promise<void> {
|
|
383
|
+
detachListeners();
|
|
384
|
+
try {
|
|
385
|
+
await module().endConnection();
|
|
386
|
+
} catch {
|
|
387
|
+
// Already down; nothing to tear down.
|
|
388
|
+
}
|
|
389
|
+
notifyConnection(false, 'disconnect');
|
|
390
|
+
},
|
|
391
|
+
|
|
392
|
+
observeConnection(listener): Unsubscribe {
|
|
393
|
+
connectionListeners.add(listener);
|
|
394
|
+
let removed = false;
|
|
395
|
+
return () => {
|
|
396
|
+
if (removed) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
removed = true;
|
|
400
|
+
connectionListeners.delete(listener);
|
|
401
|
+
};
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
async queryProducts(
|
|
405
|
+
storeProductIds: StoreProductId[],
|
|
406
|
+
productType: ProductType,
|
|
407
|
+
): Promise<ProviderProduct[]> {
|
|
408
|
+
const raw = await module().fetchProducts({
|
|
409
|
+
skus: storeProductIds,
|
|
410
|
+
type: productType === 'subscription' ? 'subs' : 'in-app',
|
|
411
|
+
});
|
|
412
|
+
return raw.map(product => normalizeProduct(product, productType));
|
|
413
|
+
},
|
|
414
|
+
|
|
415
|
+
async queryOwned(): Promise<ProviderPurchase[]> {
|
|
416
|
+
const raw = await module().getAvailablePurchases();
|
|
417
|
+
// Pending purchases are mapped through, flagged rather than filtered: the
|
|
418
|
+
// engine needs to see them to raise the pending signal (FR-038).
|
|
419
|
+
return raw.map(normalizePurchase);
|
|
420
|
+
},
|
|
421
|
+
|
|
422
|
+
async launchPurchase(
|
|
423
|
+
request: ProviderPurchaseRequest,
|
|
424
|
+
): Promise<ProviderPurchaseOutcome> {
|
|
425
|
+
try {
|
|
426
|
+
const isSubscription = request.productType === 'subscription';
|
|
427
|
+
const result = await module().requestPurchase({
|
|
428
|
+
request: {
|
|
429
|
+
android: {
|
|
430
|
+
skus: [request.storeProductId],
|
|
431
|
+
obfuscatedAccountIdAndroid: request.obfuscatedAccountId,
|
|
432
|
+
...(isSubscription && request.offerKey
|
|
433
|
+
? {
|
|
434
|
+
subscriptionOffers: [
|
|
435
|
+
{
|
|
436
|
+
sku: request.storeProductId,
|
|
437
|
+
offerToken: request.offerKey,
|
|
438
|
+
},
|
|
439
|
+
],
|
|
440
|
+
}
|
|
441
|
+
: {}),
|
|
442
|
+
...(request.replaceStoreProductId
|
|
443
|
+
? {
|
|
444
|
+
purchaseTokenAndroid: request.replaceStoreProductId,
|
|
445
|
+
replacementModeAndroid: request.prorationMode,
|
|
446
|
+
}
|
|
447
|
+
: {}),
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
type: isSubscription ? 'subs' : 'in-app',
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// The binding may resolve with the purchase, or resolve empty and deliver
|
|
454
|
+
// it through the update listener. Both are handled: the engine also
|
|
455
|
+
// ingests listener-delivered purchases, so neither path loses one.
|
|
456
|
+
const raw = Array.isArray(result) ? result[0] : result;
|
|
457
|
+
if (!raw) {
|
|
458
|
+
return { ok: false, reason: 'store_error', detail: 'no purchase returned' };
|
|
459
|
+
}
|
|
460
|
+
return { ok: true, purchase: normalizePurchase(raw as RawPurchase) };
|
|
461
|
+
} catch (error) {
|
|
462
|
+
const code = (error as { code?: string }).code;
|
|
463
|
+
return { ok: false, reason: mapErrorCode(code), detail: code };
|
|
464
|
+
}
|
|
465
|
+
},
|
|
466
|
+
|
|
467
|
+
observePurchases(listener): Unsubscribe {
|
|
468
|
+
purchaseListeners.add(listener);
|
|
469
|
+
let removed = false;
|
|
470
|
+
return () => {
|
|
471
|
+
if (removed) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
removed = true;
|
|
475
|
+
purchaseListeners.delete(listener);
|
|
476
|
+
};
|
|
477
|
+
},
|
|
478
|
+
|
|
479
|
+
async acknowledge(token: ProviderToken): Promise<void> {
|
|
480
|
+
// Idempotent at the store: acknowledging an already-acknowledged purchase
|
|
481
|
+
// is accepted, which is what FR-044 relies on.
|
|
482
|
+
await module().finishTransaction({
|
|
483
|
+
purchase: { purchaseToken: token },
|
|
484
|
+
isConsumable: false,
|
|
485
|
+
});
|
|
486
|
+
},
|
|
487
|
+
|
|
488
|
+
async consume(token: ProviderToken): Promise<void> {
|
|
489
|
+
await module().finishTransaction({
|
|
490
|
+
purchase: { purchaseToken: token },
|
|
491
|
+
isConsumable: true,
|
|
492
|
+
});
|
|
493
|
+
},
|
|
494
|
+
|
|
495
|
+
async openSubscriptionManagement(
|
|
496
|
+
storeProductId?: StoreProductId,
|
|
497
|
+
): Promise<void> {
|
|
498
|
+
// The store's own screen. Platforms forbid a self-built cancellation
|
|
499
|
+
// screen, so there is deliberately no alternative path here (FR-066).
|
|
500
|
+
const mod = module();
|
|
501
|
+
if (!mod.deepLinkToSubscriptions) {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
await mod.deepLinkToSubscriptions({ skuAndroid: storeProductId });
|
|
505
|
+
},
|
|
506
|
+
|
|
507
|
+
async confirmPriceChange(): Promise<ProviderPriceChangeOutcome> {
|
|
508
|
+
// Play has no client-side price-change confirmation flow; it notifies the
|
|
509
|
+
// user itself. Reported as unsupported rather than faked, matching
|
|
510
|
+
// `supportsPriceChangeConfirmation: false` (FR-067).
|
|
511
|
+
return { ok: false, reason: 'store_error' };
|
|
512
|
+
},
|
|
513
|
+
|
|
514
|
+
capabilities: () => ANDROID_CAPABILITIES,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provider port — **private**, never exported from `src/index.ts`.
|
|
3
|
+
*
|
|
4
|
+
* This is the boundary between the platform-neutral engine and a store-specific
|
|
5
|
+
* adapter. It exists to make three things true:
|
|
6
|
+
*
|
|
7
|
+
* 1. No store type reaches the public surface, because only the adapter ever
|
|
8
|
+
* sees one (FR-002).
|
|
9
|
+
* 2. An iOS adapter can be added by implementing this interface, changing no
|
|
10
|
+
* consumer call (FR-007).
|
|
11
|
+
* 3. The fake store is a drop-in replacement, so every outcome and ordering is
|
|
12
|
+
* testable with no device, network, or store account (NFR-007).
|
|
13
|
+
*
|
|
14
|
+
* `Provider*` types are the port's **own** normalized shapes, not the binding's.
|
|
15
|
+
* The adapter translates; nothing above it sees a `react-native-iap` value.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ProductType, Unsubscribe } from '../config/types';
|
|
19
|
+
|
|
20
|
+
/** A store's own product identifier. Confined to configuration and this layer. */
|
|
21
|
+
export type StoreProductId = string;
|
|
22
|
+
|
|
23
|
+
/** Opaque handle the store uses to identify a transaction for finalization. */
|
|
24
|
+
export type ProviderToken = string;
|
|
25
|
+
|
|
26
|
+
export type ProviderPurchaseState = 'purchased' | 'pending' | 'unspecified';
|
|
27
|
+
|
|
28
|
+
export interface ProviderOffer {
|
|
29
|
+
offerKey: string;
|
|
30
|
+
billingPeriod: string;
|
|
31
|
+
formattedPrice: string;
|
|
32
|
+
priceAmountMicros: number;
|
|
33
|
+
currencyCode: string;
|
|
34
|
+
trialPeriod?: string;
|
|
35
|
+
introductoryPrice?: {
|
|
36
|
+
formattedPrice: string;
|
|
37
|
+
period: string;
|
|
38
|
+
cycles: number;
|
|
39
|
+
};
|
|
40
|
+
/** As the **store** determined it. Never computed locally (FR-026). */
|
|
41
|
+
isEligible: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ProviderProduct {
|
|
45
|
+
storeProductId: StoreProductId;
|
|
46
|
+
productType: ProductType;
|
|
47
|
+
title: string;
|
|
48
|
+
description: string;
|
|
49
|
+
formattedPrice: string;
|
|
50
|
+
currencyCode: string;
|
|
51
|
+
priceAmountMicros: number;
|
|
52
|
+
offers: ProviderOffer[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ProviderPurchase {
|
|
56
|
+
storeProductId: StoreProductId;
|
|
57
|
+
transactionId: string;
|
|
58
|
+
token: ProviderToken;
|
|
59
|
+
state: ProviderPurchaseState;
|
|
60
|
+
purchasedAt: number;
|
|
61
|
+
isAcknowledged: boolean;
|
|
62
|
+
/** Subscription-only, and only where the platform supplies it. */
|
|
63
|
+
expiresAt?: number;
|
|
64
|
+
willAutoRenew?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Opaque material for the app's backend. Passed to the validation hook and
|
|
67
|
+
* **never** logged or put in an event (FR-074).
|
|
68
|
+
*/
|
|
69
|
+
verificationPayload?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type ProviderFailureReason =
|
|
73
|
+
| 'user_cancelled'
|
|
74
|
+
| 'already_owned'
|
|
75
|
+
| 'ineligible'
|
|
76
|
+
| 'unknown_product'
|
|
77
|
+
| 'product_unavailable_in_region'
|
|
78
|
+
| 'not_ready'
|
|
79
|
+
| 'network_error'
|
|
80
|
+
| 'timeout'
|
|
81
|
+
| 'store_error';
|
|
82
|
+
|
|
83
|
+
export type ProviderConnectOutcome =
|
|
84
|
+
| { ok: true }
|
|
85
|
+
| {
|
|
86
|
+
ok: false;
|
|
87
|
+
reason: 'no_billing_service' | 'billing_service_outdated' | 'platform_unsupported' | 'store_error';
|
|
88
|
+
retryable: boolean;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type ProviderPurchaseOutcome =
|
|
92
|
+
| { ok: true; purchase: ProviderPurchase }
|
|
93
|
+
| { ok: false; reason: ProviderFailureReason; detail?: string };
|
|
94
|
+
|
|
95
|
+
export type ProviderPriceChangeOutcome =
|
|
96
|
+
| { ok: true; confirmed: boolean }
|
|
97
|
+
| { ok: false; reason: ProviderFailureReason };
|
|
98
|
+
|
|
99
|
+
export interface ProviderPurchaseRequest {
|
|
100
|
+
storeProductId: StoreProductId;
|
|
101
|
+
productType: ProductType;
|
|
102
|
+
offerKey?: string;
|
|
103
|
+
/** App-supplied, passed straight through. Never generated or stored (FR-034). */
|
|
104
|
+
obfuscatedAccountId?: string;
|
|
105
|
+
/** Plan change: the currently-active subscription being replaced. */
|
|
106
|
+
replaceStoreProductId?: StoreProductId;
|
|
107
|
+
prorationMode?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* What the platform can actually do.
|
|
112
|
+
*
|
|
113
|
+
* This is how honest degradation is enforced rather than left to good intentions.
|
|
114
|
+
* `reportsGraceAndHoldDistinctly` is `false` on Android, because client-side Play
|
|
115
|
+
* Billing genuinely cannot separate the two: a grace-period subscription is still
|
|
116
|
+
* returned as active, and one in account hold stops being returned at all, making
|
|
117
|
+
* it indistinguishable from expired, cancelled, or refunded.
|
|
118
|
+
*
|
|
119
|
+
* The entitlement layer reads this flag and reports `indeterminate` instead of
|
|
120
|
+
* fabricating a distinction from `willAutoRenew` — which cannot separate grace
|
|
121
|
+
* from cancelled-but-valid either. Claiming otherwise would breach FR-058.
|
|
122
|
+
*/
|
|
123
|
+
export interface ProviderCapabilities {
|
|
124
|
+
supportsSubscriptions: boolean;
|
|
125
|
+
supportsPlanChange: boolean;
|
|
126
|
+
supportsPriceChangeConfirmation: boolean;
|
|
127
|
+
reportsGraceAndHoldDistinctly: boolean;
|
|
128
|
+
/** Externally-initiated purchases: false on Android, true for a future iOS adapter. */
|
|
129
|
+
supportsExternalTransactions: boolean;
|
|
130
|
+
/**
|
|
131
|
+
* Whether this adapter can move real money.
|
|
132
|
+
*
|
|
133
|
+
* `true` for the store adapter, `false` for the fake store. This is what lets
|
|
134
|
+
* FR-014's block be precise: the engine refuses a purchase when the
|
|
135
|
+
* environment may not create a production transaction **and** the provider is
|
|
136
|
+
* one that actually would. Without it the guard would either block the test
|
|
137
|
+
* suite — which must be able to buy things — or wave real purchases through in
|
|
138
|
+
* a development build.
|
|
139
|
+
*/
|
|
140
|
+
createsRealTransactions: boolean;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface ProviderPort {
|
|
144
|
+
connect(): Promise<ProviderConnectOutcome>;
|
|
145
|
+
disconnect(): Promise<void>;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Reports a **real** disconnect.
|
|
149
|
+
*
|
|
150
|
+
* The engine must never infer connectivity from an unrelated call failing.
|
|
151
|
+
* The upstream binding historically left its `connected` flag true after a
|
|
152
|
+
* disconnect and had a `reconnect()` that could report success without
|
|
153
|
+
* rebuilding anything (openiap#408), so detection belongs here, behind a
|
|
154
|
+
* contract test.
|
|
155
|
+
*/
|
|
156
|
+
observeConnection(
|
|
157
|
+
listener: (connected: boolean, reason?: string) => void,
|
|
158
|
+
): Unsubscribe;
|
|
159
|
+
|
|
160
|
+
queryProducts(
|
|
161
|
+
storeProductIds: StoreProductId[],
|
|
162
|
+
productType: ProductType,
|
|
163
|
+
): Promise<ProviderProduct[]>;
|
|
164
|
+
|
|
165
|
+
/** Owned and pending purchases. Pending ones are **flagged, never filtered** (FR-037). */
|
|
166
|
+
queryOwned(): Promise<ProviderPurchase[]>;
|
|
167
|
+
|
|
168
|
+
launchPurchase(
|
|
169
|
+
request: ProviderPurchaseRequest,
|
|
170
|
+
): Promise<ProviderPurchaseOutcome>;
|
|
171
|
+
|
|
172
|
+
/** Store-initiated updates, including a pending purchase settling later (FR-039). */
|
|
173
|
+
observePurchases(listener: (purchase: ProviderPurchase) => void): Unsubscribe;
|
|
174
|
+
|
|
175
|
+
/** Must be safe to call for an already-finalized transaction (FR-044). */
|
|
176
|
+
acknowledge(token: ProviderToken): Promise<void>;
|
|
177
|
+
/** Must be safe to call twice; the item is granted once (FR-044). */
|
|
178
|
+
consume(token: ProviderToken): Promise<void>;
|
|
179
|
+
|
|
180
|
+
openSubscriptionManagement(storeProductId?: StoreProductId): Promise<void>;
|
|
181
|
+
confirmPriceChange(
|
|
182
|
+
storeProductId: StoreProductId,
|
|
183
|
+
): Promise<ProviderPriceChangeOutcome>;
|
|
184
|
+
|
|
185
|
+
capabilities(): ProviderCapabilities;
|
|
186
|
+
}
|