@chipmobilesdk/rn-payment 0.3.0 → 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 -241
- package/README.md +20 -11
- package/package.json +54 -54
- package/skills/sdk-rn-payment/SKILL.md +174 -168
- package/src/adapter/createStoreProvider.ts +1 -0
- package/src/adapter/iap-adapter.ts +170 -57
- package/src/adapter/port.ts +18 -1
- 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 +127 -39
- package/src/testing/fakeStore.ts +19 -0
|
@@ -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
|
|
|
@@ -347,6 +433,10 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
|
|
|
347
433
|
};
|
|
348
434
|
|
|
349
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();
|
|
350
440
|
const mod = module();
|
|
351
441
|
updateSub = mod.purchaseUpdatedListener(raw => {
|
|
352
442
|
const purchase = normalizePurchase(raw);
|
|
@@ -361,6 +451,9 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
|
|
|
361
451
|
if (mapErrorCode(error.code) === 'not_ready') {
|
|
362
452
|
notifyConnection(false, 'not_prepared');
|
|
363
453
|
}
|
|
454
|
+
for (const listener of purchaseErrorListeners) {
|
|
455
|
+
listener(error);
|
|
456
|
+
}
|
|
364
457
|
});
|
|
365
458
|
};
|
|
366
459
|
|
|
@@ -450,49 +543,69 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
|
|
|
450
543
|
return raw.map(normalizePurchase);
|
|
451
544
|
},
|
|
452
545
|
|
|
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
|
-
|
|
546
|
+
/**
|
|
547
|
+
* Settles on the first outcome event for this product: a purchase update
|
|
548
|
+
* (bought or pending) or a purchase error (cancelled, already owned, …).
|
|
549
|
+
*
|
|
550
|
+
* The binding's promise is not the outcome: on Android it resolves with an
|
|
551
|
+
* empty list when the flow ends, whatever happened. Its listeners also feed
|
|
552
|
+
* `observePurchases`, so the engine sees a bought purchase twice — here and
|
|
553
|
+
* as a store update — and grants it once (FR-044).
|
|
554
|
+
*/
|
|
555
|
+
launchPurchase(request: ProviderPurchaseRequest): Promise<ProviderPurchaseOutcome> {
|
|
556
|
+
const sku = request.storeProductId;
|
|
557
|
+
return new Promise<ProviderPurchaseOutcome>(resolve => {
|
|
558
|
+
let settled = false;
|
|
559
|
+
let grace: ReturnType<typeof setTimeout> | undefined;
|
|
560
|
+
|
|
561
|
+
const onPurchase = (purchase: ProviderPurchase): void => {
|
|
562
|
+
if (purchase.storeProductId === sku) {
|
|
563
|
+
finish({ ok: true, purchase });
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
const onError = (error: RawPurchaseError): void => {
|
|
567
|
+
if (error.productId && error.productId !== sku) {
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
finish({ ok: false, reason: mapErrorCode(error.code), detail: error.code });
|
|
571
|
+
};
|
|
572
|
+
const fail = (error: unknown): void => {
|
|
573
|
+
const code = (error as { code?: string } | undefined)?.code;
|
|
574
|
+
finish({ ok: false, reason: mapErrorCode(code), detail: code });
|
|
575
|
+
};
|
|
576
|
+
function finish(outcome: ProviderPurchaseOutcome): void {
|
|
577
|
+
if (settled) {
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
settled = true;
|
|
581
|
+
clearTimeout(grace);
|
|
582
|
+
purchaseListeners.delete(onPurchase);
|
|
583
|
+
purchaseErrorListeners.delete(onError);
|
|
584
|
+
resolve(outcome);
|
|
490
585
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
586
|
+
|
|
587
|
+
purchaseListeners.add(onPurchase);
|
|
588
|
+
purchaseErrorListeners.add(onError);
|
|
589
|
+
try {
|
|
590
|
+
module()
|
|
591
|
+
.requestPurchase(toRequestProps(request))
|
|
592
|
+
.then(() => {
|
|
593
|
+
if (!settled) {
|
|
594
|
+
grace = setTimeout(
|
|
595
|
+
() =>
|
|
596
|
+
finish({
|
|
597
|
+
ok: false,
|
|
598
|
+
reason: 'store_error',
|
|
599
|
+
detail: 'no purchase event',
|
|
600
|
+
}),
|
|
601
|
+
RESULT_EVENT_GRACE_MS,
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
}, fail);
|
|
605
|
+
} catch (error) {
|
|
606
|
+
fail(error);
|
|
607
|
+
}
|
|
608
|
+
});
|
|
496
609
|
},
|
|
497
610
|
|
|
498
611
|
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/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
|
}
|
package/src/engine/engine.ts
CHANGED
|
@@ -186,7 +186,8 @@ export function createEngine(
|
|
|
186
186
|
// Both run before anything connects: a bad catalog or an unsafe environment
|
|
187
187
|
// must fail at configuration time, not when a user taps Buy.
|
|
188
188
|
assertEnvironmentSafe(config);
|
|
189
|
-
const
|
|
189
|
+
const platform = deps.platform ?? 'android';
|
|
190
|
+
const resolved = validateConfig(config, platform);
|
|
190
191
|
|
|
191
192
|
const now = deps.now ?? (() => Date.now());
|
|
192
193
|
const provider = deps.provider;
|
|
@@ -204,6 +205,8 @@ export function createEngine(
|
|
|
204
205
|
|
|
205
206
|
const initFlight = createSingleFlight<InitOutcome>();
|
|
206
207
|
const syncFlight = createSingleFlight<SyncOutcome>();
|
|
208
|
+
/** Finalize drain and revalidation, joined the same way but outside the sync flight. */
|
|
209
|
+
const settleFlight = createSingleFlight<void>();
|
|
207
210
|
const foregroundLimiter = createRateLimiter(
|
|
208
211
|
resolved.foregroundSyncMinIntervalMs,
|
|
209
212
|
now,
|
|
@@ -227,6 +230,12 @@ export function createEngine(
|
|
|
227
230
|
let purchaseSub: Unsubscribe | undefined;
|
|
228
231
|
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
229
232
|
let reconnectAttempts = 0;
|
|
233
|
+
/**
|
|
234
|
+
* Set once a connection has succeeded and the watchers are attached. Recovery
|
|
235
|
+
* after an exhausted reconnect budget is only meaningful from there; a first
|
|
236
|
+
* connection that failed is the app's to retry through `initialize`.
|
|
237
|
+
*/
|
|
238
|
+
let everConnected = false;
|
|
230
239
|
// Exclusive rather than single-flight: a second purchase must be refused, not
|
|
231
240
|
// joined onto the first, and never queued (FR-031).
|
|
232
241
|
const purchaseLock = createExclusive();
|
|
@@ -425,7 +434,14 @@ export function createEngine(
|
|
|
425
434
|
};
|
|
426
435
|
|
|
427
436
|
const scheduleReconnect = (): void => {
|
|
428
|
-
if (disposed
|
|
437
|
+
if (disposed) {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (reconnectAttempts >= resolved.retry.reconnect.maxAttempts) {
|
|
441
|
+
// Out of budget for now, not for good. Reported as a retryable failure
|
|
442
|
+
// rather than left at `connecting`, which promised a reconnect nobody
|
|
443
|
+
// was going to make; `handleForeground` starts a fresh budget.
|
|
444
|
+
setStatus({ kind: 'failed', retryable: true, reconnectAttempts });
|
|
429
445
|
return;
|
|
430
446
|
}
|
|
431
447
|
const attempt = reconnectAttempts + 1;
|
|
@@ -468,6 +484,7 @@ export function createEngine(
|
|
|
468
484
|
const finalizer = createFinalizer({
|
|
469
485
|
provider,
|
|
470
486
|
retry: resolved.retry,
|
|
487
|
+
attemptTimeoutMs: resolved.timeouts.finalizeAttemptMs,
|
|
471
488
|
snapshot: () => currentSnapshot,
|
|
472
489
|
commit,
|
|
473
490
|
emit,
|
|
@@ -486,6 +503,7 @@ export function createEngine(
|
|
|
486
503
|
config,
|
|
487
504
|
resolved,
|
|
488
505
|
provider,
|
|
506
|
+
platform,
|
|
489
507
|
finalizer,
|
|
490
508
|
snapshot: () => currentSnapshot,
|
|
491
509
|
commit,
|
|
@@ -521,6 +539,8 @@ export function createEngine(
|
|
|
521
539
|
* purchase is open.
|
|
522
540
|
*/
|
|
523
541
|
const watchPurchases = (): void => {
|
|
542
|
+
// Replaced, never stacked: a second subscription would ingest every update twice.
|
|
543
|
+
purchaseSub?.();
|
|
524
544
|
purchaseSub = provider.observePurchases(purchase => {
|
|
525
545
|
if (disposed) {
|
|
526
546
|
return;
|
|
@@ -530,6 +550,7 @@ export function createEngine(
|
|
|
530
550
|
};
|
|
531
551
|
|
|
532
552
|
const watchConnection = (): void => {
|
|
553
|
+
connectionSub?.();
|
|
533
554
|
connectionSub = provider.observeConnection((connected, reason) => {
|
|
534
555
|
if (disposed) {
|
|
535
556
|
return;
|
|
@@ -567,6 +588,13 @@ export function createEngine(
|
|
|
567
588
|
};
|
|
568
589
|
}
|
|
569
590
|
|
|
591
|
+
// Already up: answered without reconnecting. `initFlight` only joins
|
|
592
|
+
// concurrent callers, so a later sequential call used to run the whole
|
|
593
|
+
// connection again and leave a second set of native listeners behind.
|
|
594
|
+
if (status.kind === 'ready') {
|
|
595
|
+
return { ok: true };
|
|
596
|
+
}
|
|
597
|
+
|
|
570
598
|
return initFlight.run(async () => {
|
|
571
599
|
if (disposed) {
|
|
572
600
|
return {
|
|
@@ -639,6 +667,7 @@ export function createEngine(
|
|
|
639
667
|
|
|
640
668
|
watchConnection();
|
|
641
669
|
watchPurchases();
|
|
670
|
+
everConnected = true;
|
|
642
671
|
setStatus({ kind: 'ready', reconnectAttempts: 0 });
|
|
643
672
|
await engine.sync('initialize');
|
|
644
673
|
// Drain after syncing, so a transaction the store still reports as owned
|
|
@@ -753,7 +782,7 @@ export function createEngine(
|
|
|
753
782
|
|
|
754
783
|
// Concurrent callers join one flight, so repeated syncs make exactly one
|
|
755
784
|
// store request (FR-063).
|
|
756
|
-
|
|
785
|
+
const outcome = await syncFlight.run(async () => {
|
|
757
786
|
try {
|
|
758
787
|
const owned = await withTimeout(
|
|
759
788
|
provider.queryOwned(),
|
|
@@ -806,17 +835,6 @@ export function createEngine(
|
|
|
806
835
|
trigger,
|
|
807
836
|
});
|
|
808
837
|
|
|
809
|
-
// Every sync is also a chance to settle what a previous session left
|
|
810
|
-
// owed. A pending purchase that completed while the app was closed
|
|
811
|
-
// arrives here, and so does a transaction whose acknowledgement failed
|
|
812
|
-
// last time (FR-039, FR-042).
|
|
813
|
-
await finalizer.drain();
|
|
814
|
-
|
|
815
|
-
// And a chance to settle a backend decision we had to defer. Done on
|
|
816
|
-
// sync rather than on a timer of ours, so recovery follows the app's
|
|
817
|
-
// own rhythm (FR-072a).
|
|
818
|
-
await purchaseFlow.revalidate();
|
|
819
|
-
|
|
820
838
|
return { ok: true as const, restoredCount, revokedCount };
|
|
821
839
|
} catch (error) {
|
|
822
840
|
const paymentError =
|
|
@@ -838,6 +856,23 @@ export function createEngine(
|
|
|
838
856
|
return { ok: false as const, error: paymentError };
|
|
839
857
|
}
|
|
840
858
|
});
|
|
859
|
+
|
|
860
|
+
if (outcome.ok) {
|
|
861
|
+
// Every sync is also a chance to settle what a previous session left
|
|
862
|
+
// owed — a pending purchase that completed while the app was closed, a
|
|
863
|
+
// transaction whose acknowledgement failed last time (FR-039, FR-042) —
|
|
864
|
+
// and a backend decision we had to defer, on the app's own rhythm
|
|
865
|
+
// rather than a timer of ours (FR-072a).
|
|
866
|
+
//
|
|
867
|
+
// Outside the sync flight on purpose. Inside it, one store call or hook
|
|
868
|
+
// that never answered held the flight, and every later sync joined that
|
|
869
|
+
// same stuck promise for the rest of the session.
|
|
870
|
+
await settleFlight.run(async () => {
|
|
871
|
+
await finalizer.drain();
|
|
872
|
+
await purchaseFlow.revalidate();
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
return outcome;
|
|
841
876
|
},
|
|
842
877
|
|
|
843
878
|
async restore(): Promise<RestoreOutcome> {
|
|
@@ -922,6 +957,19 @@ export function createEngine(
|
|
|
922
957
|
},
|
|
923
958
|
|
|
924
959
|
async handleForeground(): Promise<void> {
|
|
960
|
+
// A foreground return is the natural moment to try again after the
|
|
961
|
+
// reconnect budget ran out: the network the app lost while backgrounded
|
|
962
|
+
// is usually back. A fresh budget, and one immediate attempt.
|
|
963
|
+
if (
|
|
964
|
+
everConnected &&
|
|
965
|
+
!disposed &&
|
|
966
|
+
status.kind === 'failed' &&
|
|
967
|
+
reconnectTimer === undefined
|
|
968
|
+
) {
|
|
969
|
+
reconnectAttempts = 0;
|
|
970
|
+
setStatus({ kind: 'connecting', reconnectAttempts: 0 });
|
|
971
|
+
await reconnectOnce().catch(() => undefined);
|
|
972
|
+
}
|
|
925
973
|
// Rate-limited inside sync, so an app that calls this on every incidental
|
|
926
974
|
// foreground transition still produces at most one request per interval.
|
|
927
975
|
await engine.sync('foreground');
|