@chipmobilesdk/rn-payment 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +334 -289
- package/README.md +8 -1
- package/package.json +54 -54
- package/skills/sdk-rn-payment/SKILL.md +174 -174
- package/src/adapter/iap-adapter.ts +4 -0
- package/src/config/types.ts +15 -0
- package/src/core/timers.ts +18 -0
- package/src/diagnostics/errors.ts +11 -4
- package/src/engine/engine.ts +62 -14
- package/src/engine/finalize.ts +98 -50
- package/src/engine/purchase.ts +76 -14
- package/src/testing/fakeStore.ts +16 -0
package/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');
|
package/src/engine/finalize.ts
CHANGED
|
@@ -25,6 +25,8 @@ import type {
|
|
|
25
25
|
import type { ProviderPort } from '../adapter/port';
|
|
26
26
|
import type { ResolvedRetry } from '../core/timers';
|
|
27
27
|
import { backoffDelayMs } from '../core/retry';
|
|
28
|
+
import { withTimeout } from '../core/mutex';
|
|
29
|
+
import { PaymentError } from '../diagnostics/errors';
|
|
28
30
|
import {
|
|
29
31
|
pendingFinalizations,
|
|
30
32
|
type PersistedPurchase,
|
|
@@ -35,6 +37,8 @@ import type { PaymentEventInput } from '../diagnostics/events';
|
|
|
35
37
|
export interface FinalizerDeps {
|
|
36
38
|
provider: ProviderPort;
|
|
37
39
|
retry: ResolvedRetry;
|
|
40
|
+
/** `timeouts.finalizeAttemptMs`: one acknowledge or consume call may take no longer. */
|
|
41
|
+
attemptTimeoutMs: number;
|
|
38
42
|
snapshot: () => PersistedSnapshot;
|
|
39
43
|
commit: (next: PersistedSnapshot) => void;
|
|
40
44
|
emit: (event: PaymentEventInput) => void;
|
|
@@ -86,6 +90,25 @@ export function createFinalizer(deps: FinalizerDeps): Finalizer {
|
|
|
86
90
|
});
|
|
87
91
|
};
|
|
88
92
|
|
|
93
|
+
/**
|
|
94
|
+
* One store call under `attemptTimeoutMs`. A call that never settles used to
|
|
95
|
+
* hold this transaction's in-flight guard and, through `drain`, the sync that
|
|
96
|
+
* awaited it. Timing out counts as a failed attempt, so the retry schedule and
|
|
97
|
+
* the next-session drain both still apply.
|
|
98
|
+
*/
|
|
99
|
+
const bounded = (call: Promise<void>, operation: 'finalize' | 'consume'): Promise<void> =>
|
|
100
|
+
withTimeout(
|
|
101
|
+
call,
|
|
102
|
+
deps.attemptTimeoutMs,
|
|
103
|
+
() =>
|
|
104
|
+
new PaymentError({
|
|
105
|
+
code: 'PAYMENT_TIMEOUT',
|
|
106
|
+
message: `The store did not answer the ${operation} call in time`,
|
|
107
|
+
operation,
|
|
108
|
+
retryable: true,
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
|
|
89
112
|
const clearTimer = (transactionId: string): void => {
|
|
90
113
|
const timer = timers.get(transactionId);
|
|
91
114
|
if (timer !== undefined) {
|
|
@@ -128,7 +151,7 @@ export function createFinalizer(deps: FinalizerDeps): Finalizer {
|
|
|
128
151
|
|
|
129
152
|
let failed = false;
|
|
130
153
|
try {
|
|
131
|
-
await deps.provider.acknowledge(record.token);
|
|
154
|
+
await bounded(deps.provider.acknowledge(record.token), 'finalize');
|
|
132
155
|
} catch {
|
|
133
156
|
failed = true;
|
|
134
157
|
} finally {
|
|
@@ -157,7 +180,72 @@ export function createFinalizer(deps: FinalizerDeps): Finalizer {
|
|
|
157
180
|
}
|
|
158
181
|
|
|
159
182
|
patch(transactionId, { finalizeState: 'failed', attempts });
|
|
160
|
-
return scheduleRetry(transactionId, record.productKey, attempts);
|
|
183
|
+
return scheduleRetry(transactionId, record.productKey, attempts, 'acknowledge');
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* One consume call for a consumable the app has banked (FR-043).
|
|
188
|
+
*
|
|
189
|
+
* Refuses anything that is not a settled consumable. Consuming a subscription
|
|
190
|
+
* or a permanent unlock would tell the store the user used it up, and a
|
|
191
|
+
* pending purchase has nothing to consume yet.
|
|
192
|
+
*/
|
|
193
|
+
const consumeAttempt = async (transactionId: string): Promise<FinalizeResult> => {
|
|
194
|
+
if (deps.disposed()) {
|
|
195
|
+
return { kind: 'failed', code: 'PAYMENT_NOT_CONFIGURED' };
|
|
196
|
+
}
|
|
197
|
+
const record = find(transactionId);
|
|
198
|
+
if (!record) {
|
|
199
|
+
return { kind: 'unknown_transaction' };
|
|
200
|
+
}
|
|
201
|
+
if (record.productType !== 'one_time_consumable') {
|
|
202
|
+
return { kind: 'failed', code: 'PAYMENT_NOT_CONSUMABLE' };
|
|
203
|
+
}
|
|
204
|
+
if (record.state === 'pending') {
|
|
205
|
+
return { kind: 'failed', code: 'PAYMENT_PENDING' };
|
|
206
|
+
}
|
|
207
|
+
if (record.state === 'consumed') {
|
|
208
|
+
// Consuming twice must grant the item once (FR-044).
|
|
209
|
+
return { kind: 'already_finalized' };
|
|
210
|
+
}
|
|
211
|
+
if (inFlight.has(transactionId)) {
|
|
212
|
+
// Concurrent consume calls collapse to one, for the same reason.
|
|
213
|
+
return { kind: 'already_finalized' };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
inFlight.add(transactionId);
|
|
217
|
+
patch(transactionId, { finalizeState: 'in_flight' });
|
|
218
|
+
|
|
219
|
+
let failed = false;
|
|
220
|
+
try {
|
|
221
|
+
await bounded(deps.provider.consume(record.token), 'consume');
|
|
222
|
+
} catch {
|
|
223
|
+
failed = true;
|
|
224
|
+
} finally {
|
|
225
|
+
// Released before scheduling, for the same reason as in `attempt`.
|
|
226
|
+
inFlight.delete(transactionId);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const attempts = record.attempts + 1;
|
|
230
|
+
|
|
231
|
+
if (failed) {
|
|
232
|
+
patch(transactionId, { finalizeState: 'failed', attempts });
|
|
233
|
+
return scheduleRetry(transactionId, record.productKey, attempts, 'consume');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
clearTimer(transactionId);
|
|
237
|
+
// `consumed` is what stops it being an entitlement source, so a
|
|
238
|
+
// consumable never becomes a permanent unlock.
|
|
239
|
+
patch(transactionId, {
|
|
240
|
+
state: 'consumed',
|
|
241
|
+
finalizeState: 'done',
|
|
242
|
+
attempts,
|
|
243
|
+
});
|
|
244
|
+
deps.emit({
|
|
245
|
+
type: 'transaction_consumed',
|
|
246
|
+
productKey: record.productKey,
|
|
247
|
+
});
|
|
248
|
+
return { kind: 'finalized' };
|
|
161
249
|
};
|
|
162
250
|
|
|
163
251
|
/**
|
|
@@ -172,6 +260,10 @@ export function createFinalizer(deps: FinalizerDeps): Finalizer {
|
|
|
172
260
|
transactionId: string,
|
|
173
261
|
productKey: ProductKey,
|
|
174
262
|
attempts: number,
|
|
263
|
+
// The timer must repeat the call that failed. Retrying a failed consume
|
|
264
|
+
// through `attempt` was a no-op — `attempt` skips consumables — while the
|
|
265
|
+
// event still reported a retry as scheduled.
|
|
266
|
+
operation: 'acknowledge' | 'consume',
|
|
175
267
|
): FinalizeResult => {
|
|
176
268
|
if (deps.disposed()) {
|
|
177
269
|
return { kind: 'failed', code: 'PAYMENT_FINALIZE_FAILED' };
|
|
@@ -187,7 +279,8 @@ export function createFinalizer(deps: FinalizerDeps): Finalizer {
|
|
|
187
279
|
transactionId,
|
|
188
280
|
setTimeoutFn(() => {
|
|
189
281
|
timers.delete(transactionId);
|
|
190
|
-
|
|
282
|
+
const retry = operation === 'consume' ? consumeAttempt : attempt;
|
|
283
|
+
retry(transactionId).catch(() => undefined);
|
|
191
284
|
}, delayMs),
|
|
192
285
|
);
|
|
193
286
|
|
|
@@ -229,53 +322,8 @@ export function createFinalizer(deps: FinalizerDeps): Finalizer {
|
|
|
229
322
|
}
|
|
230
323
|
},
|
|
231
324
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if (!record) {
|
|
235
|
-
return { kind: 'unknown_transaction' };
|
|
236
|
-
}
|
|
237
|
-
if (record.state === 'consumed') {
|
|
238
|
-
// Consuming twice must grant the item once (FR-044).
|
|
239
|
-
return { kind: 'already_finalized' };
|
|
240
|
-
}
|
|
241
|
-
if (inFlight.has(ref.transactionId)) {
|
|
242
|
-
// Concurrent consume calls collapse to one, for the same reason.
|
|
243
|
-
return { kind: 'already_finalized' };
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
inFlight.add(ref.transactionId);
|
|
247
|
-
patch(ref.transactionId, { finalizeState: 'in_flight' });
|
|
248
|
-
|
|
249
|
-
let failed = false;
|
|
250
|
-
try {
|
|
251
|
-
await deps.provider.consume(record.token);
|
|
252
|
-
} catch {
|
|
253
|
-
failed = true;
|
|
254
|
-
} finally {
|
|
255
|
-
// Released before scheduling, for the same reason as in `attempt`.
|
|
256
|
-
inFlight.delete(ref.transactionId);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
const attempts = record.attempts + 1;
|
|
260
|
-
|
|
261
|
-
if (failed) {
|
|
262
|
-
patch(ref.transactionId, { finalizeState: 'failed', attempts });
|
|
263
|
-
return scheduleRetry(ref.transactionId, record.productKey, attempts);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
clearTimer(ref.transactionId);
|
|
267
|
-
// `consumed` is what stops it being an entitlement source, so a
|
|
268
|
-
// consumable never becomes a permanent unlock.
|
|
269
|
-
patch(ref.transactionId, {
|
|
270
|
-
state: 'consumed',
|
|
271
|
-
finalizeState: 'done',
|
|
272
|
-
attempts,
|
|
273
|
-
});
|
|
274
|
-
deps.emit({
|
|
275
|
-
type: 'transaction_consumed',
|
|
276
|
-
productKey: record.productKey,
|
|
277
|
-
});
|
|
278
|
-
return { kind: 'finalized' };
|
|
325
|
+
consume(ref: TransactionRef): Promise<FinalizeResult> {
|
|
326
|
+
return consumeAttempt(ref.transactionId);
|
|
279
327
|
},
|
|
280
328
|
|
|
281
329
|
unfinalized(): UnfinalizedSummary[] {
|
package/src/engine/purchase.ts
CHANGED
|
@@ -23,7 +23,7 @@ import type {
|
|
|
23
23
|
PurchaseResult,
|
|
24
24
|
TransactionRef,
|
|
25
25
|
} from '../config/types';
|
|
26
|
-
import type { ResolvedConfig } from '../config/validate';
|
|
26
|
+
import type { ResolvedConfig, TargetPlatform } from '../config/validate';
|
|
27
27
|
import type {
|
|
28
28
|
ProviderFailureReason,
|
|
29
29
|
ProviderPort,
|
|
@@ -34,6 +34,7 @@ import type {
|
|
|
34
34
|
} from '../adapter/port';
|
|
35
35
|
import type { ExclusiveOutcome } from '../core/mutex';
|
|
36
36
|
import { withTimeout } from '../core/mutex';
|
|
37
|
+
import { PURCHASE_FLOW_CEILING_MS } from '../core/timers';
|
|
37
38
|
import { PaymentError } from '../diagnostics/errors';
|
|
38
39
|
import type { PaymentEventInput } from '../diagnostics/events';
|
|
39
40
|
import {
|
|
@@ -48,6 +49,7 @@ export interface PurchaseFlowDeps {
|
|
|
48
49
|
config: PaymentConfig;
|
|
49
50
|
resolved: ResolvedConfig;
|
|
50
51
|
provider: ProviderPort;
|
|
52
|
+
platform: TargetPlatform;
|
|
51
53
|
finalizer: Finalizer;
|
|
52
54
|
snapshot: () => PersistedSnapshot;
|
|
53
55
|
commit: (next: PersistedSnapshot) => void;
|
|
@@ -162,6 +164,29 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
|
162
164
|
};
|
|
163
165
|
};
|
|
164
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Calls the app's hook under `timeouts.validateMs`.
|
|
169
|
+
*
|
|
170
|
+
* Unbounded, a hook that never answers held the purchase lock and, through
|
|
171
|
+
* revalidation, the sync flight for the rest of the session. A timeout rejects,
|
|
172
|
+
* so callers treat it exactly like a hook that throws: unavailable (FR-072b).
|
|
173
|
+
*/
|
|
174
|
+
const askHook = (
|
|
175
|
+
hook: NonNullable<PaymentConfig['validateTransaction']>,
|
|
176
|
+
candidate: Parameters<NonNullable<PaymentConfig['validateTransaction']>>[0],
|
|
177
|
+
) =>
|
|
178
|
+
withTimeout(
|
|
179
|
+
hook(candidate),
|
|
180
|
+
resolved.timeouts.validateMs,
|
|
181
|
+
() =>
|
|
182
|
+
new PaymentError({
|
|
183
|
+
code: 'PAYMENT_VALIDATION_UNAVAILABLE',
|
|
184
|
+
message: 'The validation hook did not answer in time',
|
|
185
|
+
operation: 'validate',
|
|
186
|
+
retryable: true,
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
|
|
165
190
|
/**
|
|
166
191
|
* Runs the app's backend check, if configured.
|
|
167
192
|
*
|
|
@@ -185,11 +210,13 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
|
185
210
|
|
|
186
211
|
const entry = resolved.entries.get(productKey);
|
|
187
212
|
try {
|
|
188
|
-
const decision = await hook
|
|
213
|
+
const decision = await askHook(hook, {
|
|
189
214
|
productKey,
|
|
190
215
|
productType: entry?.productType ?? 'one_time_permanent',
|
|
191
216
|
verificationPayload: purchase.verificationPayload ?? '',
|
|
192
217
|
correlationId,
|
|
218
|
+
transactionId: purchase.transactionId,
|
|
219
|
+
platform: deps.platform,
|
|
193
220
|
});
|
|
194
221
|
deps.emit({
|
|
195
222
|
type: 'validation_decided',
|
|
@@ -209,6 +236,32 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
|
209
236
|
}
|
|
210
237
|
};
|
|
211
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Settlements in progress, by transaction.
|
|
241
|
+
*
|
|
242
|
+
* The Play binding delivers a launched purchase twice: to the launch and, at
|
|
243
|
+
* the same moment, to the session-long update listener. Both paths reach
|
|
244
|
+
* `settle`, and without this the hook ran twice and the purchase was settled
|
|
245
|
+
* twice. The second caller joins the first and gets the same result.
|
|
246
|
+
*/
|
|
247
|
+
const inFlightSettle = new Map<string, Promise<PurchaseResult>>();
|
|
248
|
+
|
|
249
|
+
const settleOnce = (
|
|
250
|
+
purchase: ProviderPurchase,
|
|
251
|
+
productKey: ProductKey,
|
|
252
|
+
correlationId: string,
|
|
253
|
+
): Promise<PurchaseResult> => {
|
|
254
|
+
const running = inFlightSettle.get(purchase.transactionId);
|
|
255
|
+
if (running) {
|
|
256
|
+
return running;
|
|
257
|
+
}
|
|
258
|
+
const flight = settle(purchase, productKey, correlationId).finally(() => {
|
|
259
|
+
inFlightSettle.delete(purchase.transactionId);
|
|
260
|
+
});
|
|
261
|
+
inFlightSettle.set(purchase.transactionId, flight);
|
|
262
|
+
return flight;
|
|
263
|
+
};
|
|
264
|
+
|
|
212
265
|
/** Persists a completed purchase, grants it, then finalizes. */
|
|
213
266
|
const settle = async (
|
|
214
267
|
purchase: ProviderPurchase,
|
|
@@ -264,8 +317,16 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
|
264
317
|
purchase: ProviderPurchase,
|
|
265
318
|
productKey: ProductKey,
|
|
266
319
|
): PurchaseResult => {
|
|
320
|
+
// The same pending transaction arrives from the launch and from the update
|
|
321
|
+
// listener (see `inFlightSettle`). The signal is about a purchase becoming
|
|
322
|
+
// pending, so it fires once per transaction, not once per delivery.
|
|
323
|
+
const alreadyPending = deps
|
|
324
|
+
.snapshot()
|
|
325
|
+
.purchases.some(p => p.transactionId === purchase.transactionId && p.state === 'pending');
|
|
267
326
|
upsert(recordFor(purchase, productKey));
|
|
268
|
-
|
|
327
|
+
if (!alreadyPending) {
|
|
328
|
+
deps.emit({ type: 'purchase_pending', productKey });
|
|
329
|
+
}
|
|
269
330
|
return {
|
|
270
331
|
kind: 'pending',
|
|
271
332
|
productKey,
|
|
@@ -326,18 +387,16 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
|
326
387
|
/**
|
|
327
388
|
* The launch timeout covers opening the store surface only; time the user
|
|
328
389
|
* 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
|
|
330
|
-
*
|
|
331
|
-
*
|
|
390
|
+
* nothing until the flow ends gives no "opened" moment to time, so it gets only
|
|
391
|
+
* `PURCHASE_FLOW_CEILING_MS`: far beyond any real purchase, there so a binding
|
|
392
|
+
* that never answers cannot hold the purchase lock for the whole session.
|
|
332
393
|
*/
|
|
333
394
|
const launch = (request: ProviderPurchaseRequest): Promise<ProviderPurchaseOutcome> => {
|
|
334
395
|
const launched = deps.provider.launchPurchase(request);
|
|
335
|
-
|
|
336
|
-
return launched;
|
|
337
|
-
}
|
|
396
|
+
const includesStoreTime = deps.provider.capabilities().purchaseOutcomeIncludesStoreTime;
|
|
338
397
|
return withTimeout(
|
|
339
398
|
launched,
|
|
340
|
-
resolved.timeouts.purchaseLaunchMs,
|
|
399
|
+
includesStoreTime ? PURCHASE_FLOW_CEILING_MS : resolved.timeouts.purchaseLaunchMs,
|
|
341
400
|
() =>
|
|
342
401
|
new PaymentError({
|
|
343
402
|
code: 'PAYMENT_TIMEOUT',
|
|
@@ -447,7 +506,7 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
|
447
506
|
}
|
|
448
507
|
|
|
449
508
|
deps.emit({ type: 'purchase_succeeded', productKey, correlationId });
|
|
450
|
-
return
|
|
509
|
+
return settleOnce(outcome.purchase, productKey, correlationId);
|
|
451
510
|
});
|
|
452
511
|
|
|
453
512
|
if (!attempt.acquired) {
|
|
@@ -492,7 +551,7 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
|
492
551
|
return;
|
|
493
552
|
}
|
|
494
553
|
|
|
495
|
-
await
|
|
554
|
+
await settleOnce(purchase, productKey, deps.nextCorrelationId());
|
|
496
555
|
},
|
|
497
556
|
|
|
498
557
|
async revalidate(): Promise<void> {
|
|
@@ -509,14 +568,17 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
|
509
568
|
const entry = resolved.entries.get(record.productKey);
|
|
510
569
|
let approved: boolean;
|
|
511
570
|
try {
|
|
512
|
-
const decision = await hook
|
|
571
|
+
const decision = await askHook(hook, {
|
|
513
572
|
productKey: record.productKey,
|
|
514
573
|
productType: entry?.productType ?? 'one_time_permanent',
|
|
515
574
|
// Re-validation deliberately carries no payload: the original
|
|
516
575
|
// verification material is not persisted (FR-056), so the backend
|
|
517
|
-
// re-checks by transaction against its own record of the purchase
|
|
576
|
+
// re-checks by transaction against its own record of the purchase —
|
|
577
|
+
// which is why the transaction id has to travel with it.
|
|
518
578
|
verificationPayload: '',
|
|
519
579
|
correlationId: deps.nextCorrelationId(),
|
|
580
|
+
transactionId: record.transactionId,
|
|
581
|
+
platform: deps.platform,
|
|
520
582
|
});
|
|
521
583
|
approved = decision.approved;
|
|
522
584
|
} catch {
|
package/src/testing/fakeStore.ts
CHANGED
|
@@ -32,6 +32,13 @@ export interface FakeStoreOptions {
|
|
|
32
32
|
capabilities?: Partial<ProviderCapabilities>;
|
|
33
33
|
products?: ProviderProduct[];
|
|
34
34
|
owned?: ProviderPurchase[];
|
|
35
|
+
/**
|
|
36
|
+
* `'android-16.7'` mirrors the Play binding the real adapter wraps: a launched
|
|
37
|
+
* purchase is also delivered through `observePurchases`, so the engine sees it
|
|
38
|
+
* twice, and capabilities match Android's. The default delivers it once, which
|
|
39
|
+
* is simpler but hides any double-settle on the device path.
|
|
40
|
+
*/
|
|
41
|
+
mode?: 'default' | 'android-16.7';
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
export interface FakeStore extends ProviderPort {
|
|
@@ -127,8 +134,10 @@ export function createFakeStore(options: FakeStoreOptions = {}): FakeStore {
|
|
|
127
134
|
let connectOutcome: ProviderConnectOutcome = options.connectOutcome ?? {
|
|
128
135
|
ok: true,
|
|
129
136
|
};
|
|
137
|
+
const androidBinding = options.mode === 'android-16.7';
|
|
130
138
|
let capabilities: ProviderCapabilities = {
|
|
131
139
|
...DEFAULT_CAPABILITIES,
|
|
140
|
+
...(androidBinding ? { supportsPriceChangeConfirmation: false } : {}),
|
|
132
141
|
...options.capabilities,
|
|
133
142
|
};
|
|
134
143
|
let products = [...(options.products ?? [])];
|
|
@@ -231,6 +240,13 @@ export function createFakeStore(options: FakeStoreOptions = {}): FakeStore {
|
|
|
231
240
|
...script.overrides,
|
|
232
241
|
});
|
|
233
242
|
owned = [...owned.filter(p => p.transactionId !== purchase.transactionId), purchase];
|
|
243
|
+
if (androidBinding) {
|
|
244
|
+
// The binding's update listener fires for every observer, the session-long
|
|
245
|
+
// one included, before the launch settles.
|
|
246
|
+
for (const listener of [...purchaseListeners]) {
|
|
247
|
+
listener(purchase);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
234
250
|
return { ok: true, purchase };
|
|
235
251
|
},
|
|
236
252
|
|