@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
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,14 +23,18 @@ 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,
|
|
30
30
|
ProviderPurchase,
|
|
31
|
+
ProviderPurchaseOutcome,
|
|
32
|
+
ProviderPurchaseRequest,
|
|
33
|
+
StoreProductId,
|
|
31
34
|
} from '../adapter/port';
|
|
32
35
|
import type { ExclusiveOutcome } from '../core/mutex';
|
|
33
36
|
import { withTimeout } from '../core/mutex';
|
|
37
|
+
import { PURCHASE_FLOW_CEILING_MS } from '../core/timers';
|
|
34
38
|
import { PaymentError } from '../diagnostics/errors';
|
|
35
39
|
import type { PaymentEventInput } from '../diagnostics/events';
|
|
36
40
|
import {
|
|
@@ -45,6 +49,7 @@ export interface PurchaseFlowDeps {
|
|
|
45
49
|
config: PaymentConfig;
|
|
46
50
|
resolved: ResolvedConfig;
|
|
47
51
|
provider: ProviderPort;
|
|
52
|
+
platform: TargetPlatform;
|
|
48
53
|
finalizer: Finalizer;
|
|
49
54
|
snapshot: () => PersistedSnapshot;
|
|
50
55
|
commit: (next: PersistedSnapshot) => void;
|
|
@@ -159,6 +164,29 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
|
159
164
|
};
|
|
160
165
|
};
|
|
161
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
|
+
|
|
162
190
|
/**
|
|
163
191
|
* Runs the app's backend check, if configured.
|
|
164
192
|
*
|
|
@@ -182,11 +210,13 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
|
182
210
|
|
|
183
211
|
const entry = resolved.entries.get(productKey);
|
|
184
212
|
try {
|
|
185
|
-
const decision = await hook
|
|
213
|
+
const decision = await askHook(hook, {
|
|
186
214
|
productKey,
|
|
187
215
|
productType: entry?.productType ?? 'one_time_permanent',
|
|
188
216
|
verificationPayload: purchase.verificationPayload ?? '',
|
|
189
217
|
correlationId,
|
|
218
|
+
transactionId: purchase.transactionId,
|
|
219
|
+
platform: deps.platform,
|
|
190
220
|
});
|
|
191
221
|
deps.emit({
|
|
192
222
|
type: 'validation_decided',
|
|
@@ -206,6 +236,32 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
|
206
236
|
}
|
|
207
237
|
};
|
|
208
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
|
+
|
|
209
265
|
/** Persists a completed purchase, grants it, then finalizes. */
|
|
210
266
|
const settle = async (
|
|
211
267
|
purchase: ProviderPurchase,
|
|
@@ -261,8 +317,16 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
|
261
317
|
purchase: ProviderPurchase,
|
|
262
318
|
productKey: ProductKey,
|
|
263
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');
|
|
264
326
|
upsert(recordFor(purchase, productKey));
|
|
265
|
-
|
|
327
|
+
if (!alreadyPending) {
|
|
328
|
+
deps.emit({ type: 'purchase_pending', productKey });
|
|
329
|
+
}
|
|
266
330
|
return {
|
|
267
331
|
kind: 'pending',
|
|
268
332
|
productKey,
|
|
@@ -291,6 +355,58 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
|
291
355
|
return { kind: 'store_error', productKey, code: 'PAYMENT_STORE_ERROR' };
|
|
292
356
|
};
|
|
293
357
|
|
|
358
|
+
/** What the store is asked for: the product and, for a plan change, what it replaces. */
|
|
359
|
+
const launchRequest = (
|
|
360
|
+
productKey: ProductKey,
|
|
361
|
+
storeProductId: StoreProductId,
|
|
362
|
+
options?: InternalPurchaseOptions,
|
|
363
|
+
): ProviderPurchaseRequest => {
|
|
364
|
+
const replaceKey = options?.replaceProductKey;
|
|
365
|
+
return {
|
|
366
|
+
storeProductId,
|
|
367
|
+
productType: resolved.entries.get(productKey)?.productType ?? 'one_time_permanent',
|
|
368
|
+
offerKey: options?.offerKey,
|
|
369
|
+
obfuscatedAccountId: options?.obfuscatedAccountId,
|
|
370
|
+
// Present only for a plan change. The store needs the token of the
|
|
371
|
+
// subscription being replaced so it migrates the existing purchase instead
|
|
372
|
+
// of creating a second one (FR-065), and its product id to apply the
|
|
373
|
+
// replacement mode.
|
|
374
|
+
replacePurchaseToken: replaceKey
|
|
375
|
+
? deps
|
|
376
|
+
.snapshot()
|
|
377
|
+
.purchases.find(p => p.productKey === replaceKey && p.state === 'purchased')
|
|
378
|
+
?.token
|
|
379
|
+
: undefined,
|
|
380
|
+
replaceStoreProductId: replaceKey
|
|
381
|
+
? storeIdForProductKey(resolved, replaceKey)
|
|
382
|
+
: undefined,
|
|
383
|
+
prorationMode: options?.prorationMode,
|
|
384
|
+
};
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* The launch timeout covers opening the store surface only; time the user
|
|
389
|
+
* spends inside it is the store's, not ours (FR-035). A provider that reports
|
|
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.
|
|
393
|
+
*/
|
|
394
|
+
const launch = (request: ProviderPurchaseRequest): Promise<ProviderPurchaseOutcome> => {
|
|
395
|
+
const launched = deps.provider.launchPurchase(request);
|
|
396
|
+
const includesStoreTime = deps.provider.capabilities().purchaseOutcomeIncludesStoreTime;
|
|
397
|
+
return withTimeout(
|
|
398
|
+
launched,
|
|
399
|
+
includesStoreTime ? PURCHASE_FLOW_CEILING_MS : resolved.timeouts.purchaseLaunchMs,
|
|
400
|
+
() =>
|
|
401
|
+
new PaymentError({
|
|
402
|
+
code: 'PAYMENT_TIMEOUT',
|
|
403
|
+
message: 'Opening the purchase surface timed out',
|
|
404
|
+
operation: 'purchase',
|
|
405
|
+
retryable: true,
|
|
406
|
+
}),
|
|
407
|
+
);
|
|
408
|
+
};
|
|
409
|
+
|
|
294
410
|
return {
|
|
295
411
|
async purchase(
|
|
296
412
|
productKey: ProductKey,
|
|
@@ -325,7 +441,6 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
|
325
441
|
};
|
|
326
442
|
}
|
|
327
443
|
|
|
328
|
-
const entry = resolved.entries.get(productKey);
|
|
329
444
|
const correlationId = deps.nextCorrelationId();
|
|
330
445
|
|
|
331
446
|
// Exclusive, not single-flight: a second purchase is refused rather than
|
|
@@ -335,37 +450,7 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
|
335
450
|
|
|
336
451
|
let outcome;
|
|
337
452
|
try {
|
|
338
|
-
outcome = await
|
|
339
|
-
deps.provider.launchPurchase({
|
|
340
|
-
storeProductId,
|
|
341
|
-
productType: entry?.productType ?? 'one_time_permanent',
|
|
342
|
-
offerKey: options?.offerKey,
|
|
343
|
-
obfuscatedAccountId: options?.obfuscatedAccountId,
|
|
344
|
-
// Present only for a plan change. The store needs the token of
|
|
345
|
-
// the subscription being replaced so it migrates the existing
|
|
346
|
-
// purchase instead of creating a second one (FR-065).
|
|
347
|
-
replaceStoreProductId: options?.replaceProductKey
|
|
348
|
-
? deps
|
|
349
|
-
.snapshot()
|
|
350
|
-
.purchases.find(
|
|
351
|
-
p =>
|
|
352
|
-
p.productKey === options.replaceProductKey &&
|
|
353
|
-
p.state === 'purchased',
|
|
354
|
-
)?.token
|
|
355
|
-
: undefined,
|
|
356
|
-
prorationMode: options?.prorationMode,
|
|
357
|
-
}),
|
|
358
|
-
// Covers opening the store surface only. Time the user spends
|
|
359
|
-
// inside it is the store's, not ours (FR-035).
|
|
360
|
-
resolved.timeouts.purchaseLaunchMs,
|
|
361
|
-
() =>
|
|
362
|
-
new PaymentError({
|
|
363
|
-
code: 'PAYMENT_TIMEOUT',
|
|
364
|
-
message: 'Opening the purchase surface timed out',
|
|
365
|
-
operation: 'purchase',
|
|
366
|
-
retryable: true,
|
|
367
|
-
}),
|
|
368
|
-
);
|
|
453
|
+
outcome = await launch(launchRequest(productKey, storeProductId, options));
|
|
369
454
|
} catch {
|
|
370
455
|
deps.emit({
|
|
371
456
|
type: 'purchase_failed',
|
|
@@ -421,7 +506,7 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
|
421
506
|
}
|
|
422
507
|
|
|
423
508
|
deps.emit({ type: 'purchase_succeeded', productKey, correlationId });
|
|
424
|
-
return
|
|
509
|
+
return settleOnce(outcome.purchase, productKey, correlationId);
|
|
425
510
|
});
|
|
426
511
|
|
|
427
512
|
if (!attempt.acquired) {
|
|
@@ -466,7 +551,7 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
|
466
551
|
return;
|
|
467
552
|
}
|
|
468
553
|
|
|
469
|
-
await
|
|
554
|
+
await settleOnce(purchase, productKey, deps.nextCorrelationId());
|
|
470
555
|
},
|
|
471
556
|
|
|
472
557
|
async revalidate(): Promise<void> {
|
|
@@ -483,14 +568,17 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
|
483
568
|
const entry = resolved.entries.get(record.productKey);
|
|
484
569
|
let approved: boolean;
|
|
485
570
|
try {
|
|
486
|
-
const decision = await hook
|
|
571
|
+
const decision = await askHook(hook, {
|
|
487
572
|
productKey: record.productKey,
|
|
488
573
|
productType: entry?.productType ?? 'one_time_permanent',
|
|
489
574
|
// Re-validation deliberately carries no payload: the original
|
|
490
575
|
// verification material is not persisted (FR-056), so the backend
|
|
491
|
-
// 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.
|
|
492
578
|
verificationPayload: '',
|
|
493
579
|
correlationId: deps.nextCorrelationId(),
|
|
580
|
+
transactionId: record.transactionId,
|
|
581
|
+
platform: deps.platform,
|
|
494
582
|
});
|
|
495
583
|
approved = decision.approved;
|
|
496
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 {
|
|
@@ -98,6 +105,9 @@ const DEFAULT_CAPABILITIES: ProviderCapabilities = {
|
|
|
98
105
|
// lets a test complete a purchase while still blocking the real adapter
|
|
99
106
|
// outside production (FR-014).
|
|
100
107
|
createsRealTransactions: false,
|
|
108
|
+
// Its launch returns at once, so the engine's launch timeout applies and stays
|
|
109
|
+
// testable (FR-035).
|
|
110
|
+
purchaseOutcomeIncludesStoreTime: false,
|
|
101
111
|
};
|
|
102
112
|
|
|
103
113
|
let sequence = 0;
|
|
@@ -124,8 +134,10 @@ export function createFakeStore(options: FakeStoreOptions = {}): FakeStore {
|
|
|
124
134
|
let connectOutcome: ProviderConnectOutcome = options.connectOutcome ?? {
|
|
125
135
|
ok: true,
|
|
126
136
|
};
|
|
137
|
+
const androidBinding = options.mode === 'android-16.7';
|
|
127
138
|
let capabilities: ProviderCapabilities = {
|
|
128
139
|
...DEFAULT_CAPABILITIES,
|
|
140
|
+
...(androidBinding ? { supportsPriceChangeConfirmation: false } : {}),
|
|
129
141
|
...options.capabilities,
|
|
130
142
|
};
|
|
131
143
|
let products = [...(options.products ?? [])];
|
|
@@ -228,6 +240,13 @@ export function createFakeStore(options: FakeStoreOptions = {}): FakeStore {
|
|
|
228
240
|
...script.overrides,
|
|
229
241
|
});
|
|
230
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
|
+
}
|
|
231
250
|
return { ok: true, purchase };
|
|
232
251
|
},
|
|
233
252
|
|