@chipmobilesdk/rn-payment 0.4.0 → 0.5.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.
@@ -1,614 +1,589 @@
1
- /**
2
- * The purchase flow.
3
- *
4
- * Ordering here is deliberate and load-bearing:
5
- *
6
- * 1. Refuse early and cheaply — disabled, not ready, already busy, environment
7
- * blocked, unknown product — so nothing reaches the store that should not.
8
- * 2. Launch, and map every store outcome onto a typed result (FR-005).
9
- * 3. Pending stops here: it is persisted and signalled, but **never granted**
10
- * (FR-037).
11
- * 4. Validate with the app's backend, if one is configured, and only for a
12
- * **newly completed** purchase (FR-069a).
13
- * 5. Grant the entitlement, then finalize. In that order — the user gets what
14
- * they paid for before the bookkeeping completes, so a finalize failure never
15
- * withholds a paid entitlement. Finalization is still guaranteed, by the
16
- * persisted queue.
17
- */
18
-
19
- import type {
20
- PaymentConfig,
21
- ProductKey,
22
- PurchaseOptions,
23
- PurchaseResult,
24
- TransactionRef,
25
- } from '../config/types';
26
- import type { ResolvedConfig, TargetPlatform } from '../config/validate';
27
- import type {
28
- ProviderFailureReason,
29
- ProviderPort,
30
- ProviderPurchase,
31
- ProviderPurchaseOutcome,
32
- ProviderPurchaseRequest,
33
- StoreProductId,
34
- } from '../adapter/port';
35
- import type { ExclusiveOutcome } from '../core/mutex';
36
- import { withTimeout } from '../core/mutex';
37
- import { PURCHASE_FLOW_CEILING_MS } from '../core/timers';
38
- import { PaymentError } from '../diagnostics/errors';
39
- import type { PaymentEventInput } from '../diagnostics/events';
40
- import {
41
- resolveFinalizeState,
42
- type PersistedPurchase,
43
- type PersistedSnapshot,
44
- } from '../persistence/snapshot';
45
- import { storeIdForProductKey } from '../entitlements/mapping';
46
- import type { Finalizer } from './finalize';
47
-
48
- export interface PurchaseFlowDeps {
49
- config: PaymentConfig;
50
- resolved: ResolvedConfig;
51
- provider: ProviderPort;
52
- platform: TargetPlatform;
53
- finalizer: Finalizer;
54
- snapshot: () => PersistedSnapshot;
55
- commit: (next: PersistedSnapshot) => void;
56
- emit: (event: PaymentEventInput) => void;
57
- now: () => number;
58
- nextCorrelationId: () => string;
59
- statusKind: () => PaymentStatusKindLike;
60
- mayCreateRealTransaction: () => boolean;
61
- recordBlockedOperation: (productKey: ProductKey) => void;
62
- requestSync: (trigger: 'already_owned') => void;
63
- runExclusive: <T>(operation: () => Promise<T>) => Promise<ExclusiveOutcome<T>>;
64
- }
65
-
66
- type PaymentStatusKindLike =
67
- | 'not_started'
68
- | 'connecting'
69
- | 'ready'
70
- | 'disabled'
71
- | 'unavailable'
72
- | 'failed';
73
-
74
- /** Maps a provider failure onto the public vocabulary, so no store code leaks out. */
75
- function mapFailure(
76
- reason: ProviderFailureReason,
77
- productKey: ProductKey,
78
- ): PurchaseResult {
79
- switch (reason) {
80
- case 'user_cancelled':
81
- return { kind: 'user_cancelled', productKey };
82
- case 'already_owned':
83
- return { kind: 'already_owned', productKey };
84
- case 'ineligible':
85
- return { kind: 'ineligible', productKey };
86
- case 'unknown_product':
87
- return { kind: 'unknown_product', productKey };
88
- case 'product_unavailable_in_region':
89
- return { kind: 'product_unavailable_in_region', productKey };
90
- case 'not_ready':
91
- return { kind: 'not_ready', status: 'connecting' };
92
- case 'network_error':
93
- return { kind: 'network_error', productKey };
94
- case 'timeout':
95
- return { kind: 'timeout', productKey };
96
- case 'store_error':
97
- return { kind: 'store_error', productKey, code: 'PAYMENT_STORE_ERROR' };
98
- }
99
- }
100
-
101
- /** Plan-change parameters, which only `subscriptions.ts` supplies. */
102
- export interface InternalPurchaseOptions extends PurchaseOptions {
103
- replaceProductKey?: ProductKey;
104
- prorationMode?: string;
105
- }
106
-
107
- export interface PurchaseFlow {
108
- purchase(
109
- productKey: ProductKey,
110
- options?: InternalPurchaseOptions,
111
- ): Promise<PurchaseResult>;
112
- /** Handles a store-pushed update, e.g. a pending purchase settling (FR-039). */
113
- ingest(purchase: ProviderPurchase): Promise<void>;
114
- /**
115
- * Re-runs the backend check for transactions granted under the
116
- * failure-fallback (FR-072a).
117
- *
118
- * Called on sync, so recovery happens on the app's own rhythm rather than on a
119
- * timer of ours. A rejection here revokes, which is what bounds the exposure
120
- * that `grant_and_queue` accepts.
121
- */
122
- revalidate(): Promise<void>;
123
- }
124
-
125
- export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
126
- const { resolved } = deps;
127
-
128
- const toRef = (record: PersistedPurchase): TransactionRef => ({
129
- transactionId: record.transactionId,
130
- productKey: record.productKey,
131
- });
132
-
133
- /** Writes or replaces the record for one transaction. */
134
- const upsert = (record: PersistedPurchase): void => {
135
- const snapshot = deps.snapshot();
136
- const others = snapshot.purchases.filter(
137
- p => p.transactionId !== record.transactionId,
138
- );
139
- deps.commit({ ...snapshot, purchases: [...others, record] });
140
- };
141
-
142
- const recordFor = (
143
- purchase: ProviderPurchase,
144
- productKey: ProductKey,
145
- ): PersistedPurchase => {
146
- const entry = resolved.entries.get(productKey);
147
- const existing = deps
148
- .snapshot()
149
- .purchases.find(p => p.transactionId === purchase.transactionId);
150
- return {
151
- transactionId: purchase.transactionId,
152
- productKey,
153
- productType: entry?.productType ?? 'one_time_permanent',
154
- token: purchase.token,
155
- state: purchase.state === 'pending' ? 'pending' : 'purchased',
156
- purchasedAt: purchase.purchasedAt,
157
- // Shared with the sync path so the two cannot disagree about what is still
158
- // owed a finalize call.
159
- finalizeState: resolveFinalizeState(purchase, existing),
160
- attempts: existing?.attempts ?? 0,
161
- validationState: existing?.validationState ?? 'not_required',
162
- expiresAt: purchase.expiresAt,
163
- willAutoRenew: purchase.willAutoRenew,
164
- };
165
- };
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
-
190
- /**
191
- * Runs the app's backend check, if configured.
192
- *
193
- * Called for newly completed purchases only (FR-069a) — sync and restore trust
194
- * a decision already made, which is what keeps restore working while the
195
- * backend is unreachable.
196
- */
197
- const validate = async (
198
- purchase: ProviderPurchase,
199
- productKey: ProductKey,
200
- correlationId: string,
201
- ): Promise<
202
- | { outcome: 'approved' }
203
- | { outcome: 'rejected'; reason?: string }
204
- | { outcome: 'granted_pending_revalidation' }
205
- > => {
206
- const hook = deps.config.validateTransaction;
207
- if (!hook) {
208
- return { outcome: 'approved' };
209
- }
210
-
211
- const entry = resolved.entries.get(productKey);
212
- try {
213
- const decision = await askHook(hook, {
214
- productKey,
215
- productType: entry?.productType ?? 'one_time_permanent',
216
- verificationPayload: purchase.verificationPayload ?? '',
217
- correlationId,
218
- transactionId: purchase.transactionId,
219
- platform: deps.platform,
220
- });
221
- deps.emit({
222
- type: 'validation_decided',
223
- productKey,
224
- decision: decision.approved ? 'approved' : 'rejected',
225
- });
226
- return decision.approved
227
- ? { outcome: 'approved' }
228
- : { outcome: 'rejected', reason: decision.reason };
229
- } catch {
230
- // A hook that throws is unavailable, never an implicit approval (FR-072b).
231
- const fallback = resolved.onValidationUnavailable;
232
- deps.emit({ type: 'validation_unavailable', productKey, fallback });
233
- return fallback === 'grant_and_queue'
234
- ? { outcome: 'granted_pending_revalidation' }
235
- : { outcome: 'rejected', reason: 'validation_unavailable' };
236
- }
237
- };
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
-
265
- /** Persists a completed purchase, grants it, then finalizes. */
266
- const settle = async (
267
- purchase: ProviderPurchase,
268
- productKey: ProductKey,
269
- correlationId: string,
270
- ): Promise<PurchaseResult> => {
271
- const decision = await validate(purchase, productKey, correlationId);
272
-
273
- if (decision.outcome === 'rejected') {
274
- upsert({
275
- ...recordFor(purchase, productKey),
276
- validationState: 'rejected',
277
- finalizeState: 'queued',
278
- });
279
- // Still finalized with the store: the money moved, so leaving it
280
- // unacknowledged would have Google refund it while our backend has
281
- // already decided the user is not entitled. Refunding is the app owner's
282
- // call, not a side effect of a bookkeeping omission.
283
- await deps.finalizer.enqueue(purchase.transactionId);
284
- return {
285
- kind: 'validation_rejected',
286
- productKey,
287
- reason: decision.reason,
288
- };
289
- }
290
-
291
- upsert({
292
- ...recordFor(purchase, productKey),
293
- validationState:
294
- decision.outcome === 'granted_pending_revalidation'
295
- ? 'queued_for_revalidation'
296
- : 'approved',
297
- finalizeState: 'queued',
298
- });
299
-
300
- // Grant is already live at this point: `commit` recomputed entitlements.
301
- // Finalize second, so a store hiccup cannot withhold what was paid for.
302
- await deps.finalizer.enqueue(purchase.transactionId);
303
-
304
- const record = deps
305
- .snapshot()
306
- .purchases.find(p => p.transactionId === purchase.transactionId);
307
- return {
308
- kind: 'success',
309
- productKey,
310
- transaction: record
311
- ? toRef(record)
312
- : { transactionId: purchase.transactionId, productKey },
313
- };
314
- };
315
-
316
- const handlePending = (
317
- purchase: ProviderPurchase,
318
- productKey: ProductKey,
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');
326
- upsert(recordFor(purchase, productKey));
327
- if (!alreadyPending) {
328
- deps.emit({ type: 'purchase_pending', productKey });
329
- }
330
- return {
331
- kind: 'pending',
332
- productKey,
333
- transaction: { transactionId: purchase.transactionId, productKey },
334
- };
335
- };
336
-
337
- /**
338
- * The store returned a purchase in a state it could not name — neither paid
339
- * nor pending. Nothing is granted, persisted or acknowledged on it.
340
- *
341
- * Retrying is the repair path: if the money did move, the store answers
342
- * `already_owned`, which re-syncs and grants (FR-033).
343
- */
344
- const handleUnspecified = (
345
- productKey: ProductKey,
346
- correlationId: string,
347
- ): PurchaseResult => {
348
- deps.emit({
349
- type: 'purchase_failed',
350
- productKey,
351
- code: 'PAYMENT_STORE_ERROR',
352
- retryable: true,
353
- correlationId,
354
- });
355
- return { kind: 'store_error', productKey, code: 'PAYMENT_STORE_ERROR' };
356
- };
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
-
410
- return {
411
- async purchase(
412
- productKey: ProductKey,
413
- options?: InternalPurchaseOptions,
414
- ): Promise<PurchaseResult> {
415
- if (!resolved.enabled) {
416
- return { kind: 'disabled' };
417
- }
418
-
419
- const status = deps.statusKind();
420
- if (status !== 'ready') {
421
- return { kind: 'not_ready', status };
422
- }
423
-
424
- const storeProductId = storeIdForProductKey(resolved, productKey);
425
- if (!storeProductId) {
426
- return { kind: 'unknown_product', productKey };
427
- }
428
-
429
- // The block that makes a developer mistake unable to move real money.
430
- // Gated on the provider actually being one that would: the fake store
431
- // must still be able to complete a purchase in a test (FR-014).
432
- if (
433
- deps.provider.capabilities().createsRealTransactions &&
434
- !deps.mayCreateRealTransaction()
435
- ) {
436
- deps.recordBlockedOperation(productKey);
437
- return {
438
- kind: 'environment_blocked',
439
- productKey,
440
- environment: deps.config.environment,
441
- };
442
- }
443
-
444
- const correlationId = deps.nextCorrelationId();
445
-
446
- // Exclusive, not single-flight: a second purchase is refused rather than
447
- // joined or queued, so no second store surface opens (FR-031).
448
- const attempt = await deps.runExclusive(async () => {
449
- deps.emit({ type: 'purchase_started', productKey, correlationId });
450
-
451
- let outcome;
452
- try {
453
- outcome = await launch(launchRequest(productKey, storeProductId, options));
454
- } catch {
455
- deps.emit({
456
- type: 'purchase_failed',
457
- productKey,
458
- code: 'PAYMENT_TIMEOUT',
459
- retryable: true,
460
- correlationId,
461
- });
462
- return { kind: 'timeout', productKey } as PurchaseResult;
463
- }
464
-
465
- if (!outcome.ok) {
466
- const mapped = mapFailure(outcome.reason, productKey);
467
-
468
- if (mapped.kind === 'user_cancelled') {
469
- // A normal outcome. Reported on its own channel so it is never
470
- // counted as a technical failure (FR-032).
471
- deps.emit({ type: 'purchase_cancelled', productKey, correlationId });
472
- return mapped;
473
- }
474
-
475
- if (mapped.kind === 'already_owned') {
476
- // How a reinstall or a new device repairs itself (FR-033).
477
- deps.requestSync('already_owned');
478
- return mapped;
479
- }
480
-
481
- deps.emit({
482
- type: 'purchase_failed',
483
- productKey,
484
- code:
485
- mapped.kind === 'network_error'
486
- ? 'PAYMENT_NETWORK_ERROR'
487
- : mapped.kind === 'unknown_product'
488
- ? 'PAYMENT_UNKNOWN_PRODUCT'
489
- : mapped.kind === 'ineligible'
490
- ? 'PAYMENT_INELIGIBLE'
491
- : mapped.kind === 'product_unavailable_in_region'
492
- ? 'PAYMENT_PRODUCT_UNAVAILABLE_IN_REGION'
493
- : 'PAYMENT_STORE_ERROR',
494
- retryable: mapped.kind === 'network_error',
495
- correlationId,
496
- });
497
- return mapped;
498
- }
499
-
500
- if (outcome.purchase.state === 'pending') {
501
- return handlePending(outcome.purchase, productKey);
502
- }
503
-
504
- if (outcome.purchase.state === 'unspecified') {
505
- return handleUnspecified(productKey, correlationId);
506
- }
507
-
508
- deps.emit({ type: 'purchase_succeeded', productKey, correlationId });
509
- return settleOnce(outcome.purchase, productKey, correlationId);
510
- });
511
-
512
- if (!attempt.acquired) {
513
- return { kind: 'purchase_in_progress' };
514
- }
515
- return attempt.value;
516
- },
517
-
518
- /**
519
- * A store-pushed update.
520
- *
521
- * This is how a pending purchase that settled between sessions is granted
522
- * without the user buying again (FR-039). It runs outside the exclusive lock
523
- * because it is not a user-initiated purchase and must not be refused just
524
- * because one is open.
525
- */
526
- async ingest(purchase: ProviderPurchase): Promise<void> {
527
- const productKey = resolved.byStoreId.get(purchase.storeProductId);
528
- if (!productKey) {
529
- return;
530
- }
531
-
532
- if (purchase.state === 'pending') {
533
- handlePending(purchase, productKey);
534
- return;
535
- }
536
-
537
- if (purchase.state === 'unspecified') {
538
- // No basis to grant or to end anything. The next sync reports what
539
- // the store actually holds.
540
- return;
541
- }
542
-
543
- const existing = deps
544
- .snapshot()
545
- .purchases.find(p => p.transactionId === purchase.transactionId);
546
-
547
- // Already settled and validated: nothing to redo, and re-running the hook
548
- // here would break FR-069a's "new purchases only" rule.
549
- if (existing && existing.state === 'purchased' && existing.validationState !== 'not_required') {
550
- await deps.finalizer.enqueue(purchase.transactionId);
551
- return;
552
- }
553
-
554
- await settleOnce(purchase, productKey, deps.nextCorrelationId());
555
- },
556
-
557
- async revalidate(): Promise<void> {
558
- const hook = deps.config.validateTransaction;
559
- if (!hook) {
560
- return;
561
- }
562
-
563
- const queued = deps
564
- .snapshot()
565
- .purchases.filter(p => p.validationState === 'queued_for_revalidation');
566
-
567
- for (const record of queued) {
568
- const entry = resolved.entries.get(record.productKey);
569
- let approved: boolean;
570
- try {
571
- const decision = await askHook(hook, {
572
- productKey: record.productKey,
573
- productType: entry?.productType ?? 'one_time_permanent',
574
- // Re-validation deliberately carries no payload: the original
575
- // verification material is not persisted (FR-056), so the backend
576
- // re-checks by transaction against its own record of the purchase —
577
- // which is why the transaction id has to travel with it.
578
- verificationPayload: '',
579
- correlationId: deps.nextCorrelationId(),
580
- transactionId: record.transactionId,
581
- platform: deps.platform,
582
- });
583
- approved = decision.approved;
584
- } catch {
585
- // Still unreachable. Leave it queued and try again next sync rather
586
- // than revoking from a paying user because our backend is down.
587
- continue;
588
- }
589
-
590
- const snapshot = deps.snapshot();
591
- deps.commit({
592
- ...snapshot,
593
- purchases: snapshot.purchases.map(p =>
594
- p.transactionId === record.transactionId
595
- ? {
596
- ...p,
597
- validationState: approved ? 'approved' : 'rejected',
598
- // A rejection on recovery revokes, which is the bound on the
599
- // exposure `grant_and_queue` accepts (FR-072a).
600
- state: approved ? p.state : 'revoked',
601
- }
602
- : p,
603
- ),
604
- });
605
-
606
- deps.emit({
607
- type: 'validation_decided',
608
- productKey: record.productKey,
609
- decision: approved ? 'approved' : 'rejected',
610
- });
611
- }
612
- },
613
- };
614
- }
1
+ /**
2
+ * The purchase flow.
3
+ *
4
+ * Ordering here is deliberate and load-bearing:
5
+ *
6
+ * 1. Refuse early and cheaply — disabled, not ready, already busy, environment
7
+ * blocked, unknown product — so nothing reaches the store that should not.
8
+ * 2. Launch, and map every store outcome onto a typed result (FR-005).
9
+ * 3. Pending stops here: it is persisted and signalled, but **never granted**
10
+ * (FR-037).
11
+ * 4. Validate with the app's backend, if one is configured, and only for a
12
+ * **newly completed** purchase (FR-069a).
13
+ * 5. Grant the entitlement, then finalize. In that order — the user gets what
14
+ * they paid for before the bookkeeping completes, so a finalize failure never
15
+ * withholds a paid entitlement. Finalization is still guaranteed, by the
16
+ * persisted queue.
17
+ */
18
+
19
+ import type {
20
+ PaymentConfig,
21
+ ProductKey,
22
+ PurchaseOptions,
23
+ PurchaseResult,
24
+ TransactionRef,
25
+ } from '../config/types';
26
+ import type { ResolvedConfig, TargetPlatform } from '../config/validate';
27
+ import type {
28
+ ProviderFailureReason,
29
+ ProviderPort,
30
+ ProviderPurchase,
31
+ ProviderPurchaseOutcome,
32
+ ProviderPurchaseRequest,
33
+ StoreProductId,
34
+ } from '../adapter/port';
35
+ import type { ExclusiveOutcome } from '../core/mutex';
36
+ import { withTimeout } from '../core/mutex';
37
+ import { PURCHASE_FLOW_CEILING_MS } from '../core/timers';
38
+ import { PaymentError } from '../diagnostics/errors';
39
+ import type { PaymentEventInput } from '../diagnostics/events';
40
+ import {
41
+ resolveFinalizeState,
42
+ type PersistedPurchase,
43
+ type PersistedSnapshot,
44
+ } from '../persistence/snapshot';
45
+ import { storeIdForProductKey } from '../entitlements/mapping';
46
+ import type { Finalizer } from './finalize';
47
+
48
+ export interface PurchaseFlowDeps {
49
+ config: PaymentConfig;
50
+ resolved: ResolvedConfig;
51
+ provider: ProviderPort;
52
+ platform: TargetPlatform;
53
+ finalizer: Finalizer;
54
+ snapshot: () => PersistedSnapshot;
55
+ commit: (next: PersistedSnapshot) => void;
56
+ emit: (event: PaymentEventInput) => void;
57
+ now: () => number;
58
+ nextCorrelationId: () => string;
59
+ statusKind: () => PaymentStatusKindLike;
60
+ mayCreateRealTransaction: () => boolean;
61
+ recordBlockedOperation: (productKey: ProductKey) => void;
62
+ requestSync: (trigger: 'already_owned') => void;
63
+ runExclusive: <T>(operation: () => Promise<T>) => Promise<ExclusiveOutcome<T>>;
64
+ }
65
+
66
+ type PaymentStatusKindLike =
67
+ 'not_started' | 'connecting' | 'ready' | 'disabled' | 'unavailable' | 'failed';
68
+
69
+ /** Maps a provider failure onto the public vocabulary, so no store code leaks out. */
70
+ function mapFailure(reason: ProviderFailureReason, productKey: ProductKey): PurchaseResult {
71
+ switch (reason) {
72
+ case 'user_cancelled':
73
+ return { kind: 'user_cancelled', productKey };
74
+ case 'already_owned':
75
+ return { kind: 'already_owned', productKey };
76
+ case 'ineligible':
77
+ return { kind: 'ineligible', productKey };
78
+ case 'unknown_product':
79
+ return { kind: 'unknown_product', productKey };
80
+ case 'product_unavailable_in_region':
81
+ return { kind: 'product_unavailable_in_region', productKey };
82
+ case 'not_ready':
83
+ return { kind: 'not_ready', status: 'connecting' };
84
+ case 'network_error':
85
+ return { kind: 'network_error', productKey };
86
+ case 'timeout':
87
+ return { kind: 'timeout', productKey };
88
+ case 'store_error':
89
+ return { kind: 'store_error', productKey, code: 'PAYMENT_STORE_ERROR' };
90
+ }
91
+ }
92
+
93
+ /** Plan-change parameters, which only `subscriptions.ts` supplies. */
94
+ export interface InternalPurchaseOptions extends PurchaseOptions {
95
+ replaceProductKey?: ProductKey;
96
+ prorationMode?: string;
97
+ }
98
+
99
+ export interface PurchaseFlow {
100
+ purchase(productKey: ProductKey, options?: InternalPurchaseOptions): Promise<PurchaseResult>;
101
+ /** Handles a store-pushed update, e.g. a pending purchase settling (FR-039). */
102
+ ingest(purchase: ProviderPurchase): Promise<void>;
103
+ /**
104
+ * Re-runs the backend check for transactions granted under the
105
+ * failure-fallback (FR-072a).
106
+ *
107
+ * Called on sync, so recovery happens on the app's own rhythm rather than on a
108
+ * timer of ours. A rejection here revokes, which is what bounds the exposure
109
+ * that `grant_and_queue` accepts.
110
+ */
111
+ revalidate(): Promise<void>;
112
+ }
113
+
114
+ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
115
+ const { resolved } = deps;
116
+
117
+ const toRef = (record: PersistedPurchase): TransactionRef => ({
118
+ transactionId: record.transactionId,
119
+ productKey: record.productKey,
120
+ });
121
+
122
+ /** Writes or replaces the record for one transaction. */
123
+ const upsert = (record: PersistedPurchase): void => {
124
+ const snapshot = deps.snapshot();
125
+ const others = snapshot.purchases.filter(p => p.transactionId !== record.transactionId);
126
+ deps.commit({ ...snapshot, purchases: [...others, record] });
127
+ };
128
+
129
+ const recordFor = (purchase: ProviderPurchase, productKey: ProductKey): PersistedPurchase => {
130
+ const entry = resolved.entries.get(productKey);
131
+ const existing = deps
132
+ .snapshot()
133
+ .purchases.find(p => p.transactionId === purchase.transactionId);
134
+ return {
135
+ transactionId: purchase.transactionId,
136
+ productKey,
137
+ productType: entry?.productType ?? 'one_time_permanent',
138
+ token: purchase.token,
139
+ state: purchase.state === 'pending' ? 'pending' : 'purchased',
140
+ purchasedAt: purchase.purchasedAt,
141
+ // Shared with the sync path so the two cannot disagree about what is still
142
+ // owed a finalize call.
143
+ finalizeState: resolveFinalizeState(purchase, existing),
144
+ attempts: existing?.attempts ?? 0,
145
+ validationState: existing?.validationState ?? 'not_required',
146
+ expiresAt: purchase.expiresAt,
147
+ willAutoRenew: purchase.willAutoRenew,
148
+ };
149
+ };
150
+
151
+ /**
152
+ * Calls the app's hook under `timeouts.validateMs`.
153
+ *
154
+ * Unbounded, a hook that never answers held the purchase lock and, through
155
+ * revalidation, the sync flight for the rest of the session. A timeout rejects,
156
+ * so callers treat it exactly like a hook that throws: unavailable (FR-072b).
157
+ */
158
+ const askHook = (
159
+ hook: NonNullable<PaymentConfig['validateTransaction']>,
160
+ candidate: Parameters<NonNullable<PaymentConfig['validateTransaction']>>[0],
161
+ ) =>
162
+ withTimeout(
163
+ hook(candidate),
164
+ resolved.timeouts.validateMs,
165
+ () =>
166
+ new PaymentError({
167
+ code: 'PAYMENT_VALIDATION_UNAVAILABLE',
168
+ message: 'The validation hook did not answer in time',
169
+ operation: 'validate',
170
+ retryable: true,
171
+ }),
172
+ );
173
+
174
+ /**
175
+ * Runs the app's backend check, if configured.
176
+ *
177
+ * Called for newly completed purchases only (FR-069a) — sync and restore trust
178
+ * a decision already made, which is what keeps restore working while the
179
+ * backend is unreachable.
180
+ */
181
+ const validate = async (
182
+ purchase: ProviderPurchase,
183
+ productKey: ProductKey,
184
+ correlationId: string,
185
+ ): Promise<
186
+ | { outcome: 'approved' }
187
+ | { outcome: 'rejected'; reason?: string }
188
+ | { outcome: 'granted_pending_revalidation' }
189
+ > => {
190
+ const hook = deps.config.validateTransaction;
191
+ if (!hook) {
192
+ return { outcome: 'approved' };
193
+ }
194
+
195
+ const entry = resolved.entries.get(productKey);
196
+ try {
197
+ const decision = await askHook(hook, {
198
+ productKey,
199
+ productType: entry?.productType ?? 'one_time_permanent',
200
+ verificationPayload: purchase.verificationPayload ?? '',
201
+ correlationId,
202
+ transactionId: purchase.transactionId,
203
+ platform: deps.platform,
204
+ });
205
+ deps.emit({
206
+ type: 'validation_decided',
207
+ productKey,
208
+ decision: decision.approved ? 'approved' : 'rejected',
209
+ });
210
+ return decision.approved
211
+ ? { outcome: 'approved' }
212
+ : { outcome: 'rejected', reason: decision.reason };
213
+ } catch {
214
+ // A hook that throws is unavailable, never an implicit approval (FR-072b).
215
+ const fallback = resolved.onValidationUnavailable;
216
+ deps.emit({ type: 'validation_unavailable', productKey, fallback });
217
+ return fallback === 'grant_and_queue'
218
+ ? { outcome: 'granted_pending_revalidation' }
219
+ : { outcome: 'rejected', reason: 'validation_unavailable' };
220
+ }
221
+ };
222
+
223
+ /**
224
+ * Settlements in progress, by transaction.
225
+ *
226
+ * The Play binding delivers a launched purchase twice: to the launch and, at
227
+ * the same moment, to the session-long update listener. Both paths reach
228
+ * `settle`, and without this the hook ran twice and the purchase was settled
229
+ * twice. The second caller joins the first and gets the same result.
230
+ */
231
+ const inFlightSettle = new Map<string, Promise<PurchaseResult>>();
232
+
233
+ const settleOnce = (
234
+ purchase: ProviderPurchase,
235
+ productKey: ProductKey,
236
+ correlationId: string,
237
+ ): Promise<PurchaseResult> => {
238
+ const running = inFlightSettle.get(purchase.transactionId);
239
+ if (running) {
240
+ return running;
241
+ }
242
+ const flight = settle(purchase, productKey, correlationId).finally(() => {
243
+ inFlightSettle.delete(purchase.transactionId);
244
+ });
245
+ inFlightSettle.set(purchase.transactionId, flight);
246
+ return flight;
247
+ };
248
+
249
+ /** Persists a completed purchase, grants it, then finalizes. */
250
+ const settle = async (
251
+ purchase: ProviderPurchase,
252
+ productKey: ProductKey,
253
+ correlationId: string,
254
+ ): Promise<PurchaseResult> => {
255
+ const decision = await validate(purchase, productKey, correlationId);
256
+
257
+ if (decision.outcome === 'rejected') {
258
+ upsert({
259
+ ...recordFor(purchase, productKey),
260
+ validationState: 'rejected',
261
+ finalizeState: 'queued',
262
+ });
263
+ // Still finalized with the store: the money moved, so leaving it
264
+ // unacknowledged would have Google refund it while our backend has
265
+ // already decided the user is not entitled. Refunding is the app owner's
266
+ // call, not a side effect of a bookkeeping omission.
267
+ await deps.finalizer.enqueue(purchase.transactionId);
268
+ return {
269
+ kind: 'validation_rejected',
270
+ productKey,
271
+ reason: decision.reason,
272
+ };
273
+ }
274
+
275
+ upsert({
276
+ ...recordFor(purchase, productKey),
277
+ validationState:
278
+ decision.outcome === 'granted_pending_revalidation'
279
+ ? 'queued_for_revalidation'
280
+ : 'approved',
281
+ finalizeState: 'queued',
282
+ });
283
+
284
+ // Grant is already live at this point: `commit` recomputed entitlements.
285
+ // Finalize second, so a store hiccup cannot withhold what was paid for.
286
+ await deps.finalizer.enqueue(purchase.transactionId);
287
+
288
+ const record = deps.snapshot().purchases.find(p => p.transactionId === purchase.transactionId);
289
+ return {
290
+ kind: 'success',
291
+ productKey,
292
+ transaction: record ? toRef(record) : { transactionId: purchase.transactionId, productKey },
293
+ };
294
+ };
295
+
296
+ const handlePending = (purchase: ProviderPurchase, productKey: ProductKey): PurchaseResult => {
297
+ // The same pending transaction arrives from the launch and from the update
298
+ // listener (see `inFlightSettle`). The signal is about a purchase becoming
299
+ // pending, so it fires once per transaction, not once per delivery.
300
+ const alreadyPending = deps
301
+ .snapshot()
302
+ .purchases.some(p => p.transactionId === purchase.transactionId && p.state === 'pending');
303
+ upsert(recordFor(purchase, productKey));
304
+ if (!alreadyPending) {
305
+ deps.emit({ type: 'purchase_pending', productKey });
306
+ }
307
+ return {
308
+ kind: 'pending',
309
+ productKey,
310
+ transaction: { transactionId: purchase.transactionId, productKey },
311
+ };
312
+ };
313
+
314
+ /**
315
+ * The store returned a purchase in a state it could not name — neither paid
316
+ * nor pending. Nothing is granted, persisted or acknowledged on it.
317
+ *
318
+ * Retrying is the repair path: if the money did move, the store answers
319
+ * `already_owned`, which re-syncs and grants (FR-033).
320
+ */
321
+ const handleUnspecified = (productKey: ProductKey, correlationId: string): PurchaseResult => {
322
+ deps.emit({
323
+ type: 'purchase_failed',
324
+ productKey,
325
+ code: 'PAYMENT_STORE_ERROR',
326
+ retryable: true,
327
+ correlationId,
328
+ });
329
+ return { kind: 'store_error', productKey, code: 'PAYMENT_STORE_ERROR' };
330
+ };
331
+
332
+ /** What the store is asked for: the product and, for a plan change, what it replaces. */
333
+ const launchRequest = (
334
+ productKey: ProductKey,
335
+ storeProductId: StoreProductId,
336
+ options?: InternalPurchaseOptions,
337
+ ): ProviderPurchaseRequest => {
338
+ const replaceKey = options?.replaceProductKey;
339
+ return {
340
+ storeProductId,
341
+ productType: resolved.entries.get(productKey)?.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 the
345
+ // subscription being replaced so it migrates the existing purchase instead
346
+ // of creating a second one (FR-065), and its product id to apply the
347
+ // replacement mode.
348
+ replacePurchaseToken: replaceKey
349
+ ? deps
350
+ .snapshot()
351
+ .purchases.find(p => p.productKey === replaceKey && p.state === 'purchased')?.token
352
+ : undefined,
353
+ replaceStoreProductId: replaceKey ? storeIdForProductKey(resolved, replaceKey) : undefined,
354
+ prorationMode: options?.prorationMode,
355
+ };
356
+ };
357
+
358
+ /**
359
+ * The launch timeout covers opening the store surface only; time the user
360
+ * spends inside it is the store's, not ours (FR-035). A provider that reports
361
+ * nothing until the flow ends gives no "opened" moment to time, so it gets only
362
+ * `PURCHASE_FLOW_CEILING_MS`: far beyond any real purchase, there so a binding
363
+ * that never answers cannot hold the purchase lock for the whole session.
364
+ */
365
+ const launch = (request: ProviderPurchaseRequest): Promise<ProviderPurchaseOutcome> => {
366
+ const launched = deps.provider.launchPurchase(request);
367
+ const includesStoreTime = deps.provider.capabilities().purchaseOutcomeIncludesStoreTime;
368
+ return withTimeout(
369
+ launched,
370
+ includesStoreTime ? PURCHASE_FLOW_CEILING_MS : resolved.timeouts.purchaseLaunchMs,
371
+ () =>
372
+ new PaymentError({
373
+ code: 'PAYMENT_TIMEOUT',
374
+ message: 'Opening the purchase surface timed out',
375
+ operation: 'purchase',
376
+ retryable: true,
377
+ }),
378
+ );
379
+ };
380
+
381
+ return {
382
+ async purchase(
383
+ productKey: ProductKey,
384
+ options?: InternalPurchaseOptions,
385
+ ): Promise<PurchaseResult> {
386
+ if (!resolved.enabled) {
387
+ return { kind: 'disabled' };
388
+ }
389
+
390
+ const status = deps.statusKind();
391
+ if (status !== 'ready') {
392
+ return { kind: 'not_ready', status };
393
+ }
394
+
395
+ const storeProductId = storeIdForProductKey(resolved, productKey);
396
+ if (!storeProductId) {
397
+ return { kind: 'unknown_product', productKey };
398
+ }
399
+
400
+ // The block that makes a developer mistake unable to move real money.
401
+ // Gated on the provider actually being one that would: the fake store
402
+ // must still be able to complete a purchase in a test (FR-014).
403
+ if (
404
+ deps.provider.capabilities().createsRealTransactions &&
405
+ !deps.mayCreateRealTransaction()
406
+ ) {
407
+ deps.recordBlockedOperation(productKey);
408
+ return {
409
+ kind: 'environment_blocked',
410
+ productKey,
411
+ environment: deps.config.environment,
412
+ };
413
+ }
414
+
415
+ const correlationId = deps.nextCorrelationId();
416
+
417
+ // Exclusive, not single-flight: a second purchase is refused rather than
418
+ // joined or queued, so no second store surface opens (FR-031).
419
+ const attempt = await deps.runExclusive(async () => {
420
+ deps.emit({ type: 'purchase_started', productKey, correlationId });
421
+
422
+ let outcome;
423
+ try {
424
+ outcome = await launch(launchRequest(productKey, storeProductId, options));
425
+ } catch {
426
+ deps.emit({
427
+ type: 'purchase_failed',
428
+ productKey,
429
+ code: 'PAYMENT_TIMEOUT',
430
+ retryable: true,
431
+ correlationId,
432
+ });
433
+ return { kind: 'timeout', productKey } as PurchaseResult;
434
+ }
435
+
436
+ if (!outcome.ok) {
437
+ const mapped = mapFailure(outcome.reason, productKey);
438
+
439
+ if (mapped.kind === 'user_cancelled') {
440
+ // A normal outcome. Reported on its own channel so it is never
441
+ // counted as a technical failure (FR-032).
442
+ deps.emit({ type: 'purchase_cancelled', productKey, correlationId });
443
+ return mapped;
444
+ }
445
+
446
+ if (mapped.kind === 'already_owned') {
447
+ // How a reinstall or a new device repairs itself (FR-033).
448
+ deps.requestSync('already_owned');
449
+ return mapped;
450
+ }
451
+
452
+ deps.emit({
453
+ type: 'purchase_failed',
454
+ productKey,
455
+ code:
456
+ mapped.kind === 'network_error'
457
+ ? 'PAYMENT_NETWORK_ERROR'
458
+ : mapped.kind === 'unknown_product'
459
+ ? 'PAYMENT_UNKNOWN_PRODUCT'
460
+ : mapped.kind === 'ineligible'
461
+ ? 'PAYMENT_INELIGIBLE'
462
+ : mapped.kind === 'product_unavailable_in_region'
463
+ ? 'PAYMENT_PRODUCT_UNAVAILABLE_IN_REGION'
464
+ : 'PAYMENT_STORE_ERROR',
465
+ retryable: mapped.kind === 'network_error',
466
+ correlationId,
467
+ });
468
+ return mapped;
469
+ }
470
+
471
+ if (outcome.purchase.state === 'pending') {
472
+ return handlePending(outcome.purchase, productKey);
473
+ }
474
+
475
+ if (outcome.purchase.state === 'unspecified') {
476
+ return handleUnspecified(productKey, correlationId);
477
+ }
478
+
479
+ deps.emit({ type: 'purchase_succeeded', productKey, correlationId });
480
+ return settleOnce(outcome.purchase, productKey, correlationId);
481
+ });
482
+
483
+ if (!attempt.acquired) {
484
+ return { kind: 'purchase_in_progress' };
485
+ }
486
+ return attempt.value;
487
+ },
488
+
489
+ /**
490
+ * A store-pushed update.
491
+ *
492
+ * This is how a pending purchase that settled between sessions is granted
493
+ * without the user buying again (FR-039). It runs outside the exclusive lock
494
+ * because it is not a user-initiated purchase and must not be refused just
495
+ * because one is open.
496
+ */
497
+ async ingest(purchase: ProviderPurchase): Promise<void> {
498
+ const productKey = resolved.byStoreId.get(purchase.storeProductId);
499
+ if (!productKey) {
500
+ return;
501
+ }
502
+
503
+ if (purchase.state === 'pending') {
504
+ handlePending(purchase, productKey);
505
+ return;
506
+ }
507
+
508
+ if (purchase.state === 'unspecified') {
509
+ // No basis to grant or to end anything. The next sync reports what
510
+ // the store actually holds.
511
+ return;
512
+ }
513
+
514
+ const existing = deps
515
+ .snapshot()
516
+ .purchases.find(p => p.transactionId === purchase.transactionId);
517
+
518
+ // Already settled and validated: nothing to redo, and re-running the hook
519
+ // here would break FR-069a's "new purchases only" rule.
520
+ if (
521
+ existing &&
522
+ existing.state === 'purchased' &&
523
+ existing.validationState !== 'not_required'
524
+ ) {
525
+ await deps.finalizer.enqueue(purchase.transactionId);
526
+ return;
527
+ }
528
+
529
+ await settleOnce(purchase, productKey, deps.nextCorrelationId());
530
+ },
531
+
532
+ async revalidate(): Promise<void> {
533
+ const hook = deps.config.validateTransaction;
534
+ if (!hook) {
535
+ return;
536
+ }
537
+
538
+ const queued = deps
539
+ .snapshot()
540
+ .purchases.filter(p => p.validationState === 'queued_for_revalidation');
541
+
542
+ for (const record of queued) {
543
+ const entry = resolved.entries.get(record.productKey);
544
+ let approved: boolean;
545
+ try {
546
+ const decision = await askHook(hook, {
547
+ productKey: record.productKey,
548
+ productType: entry?.productType ?? 'one_time_permanent',
549
+ // Re-validation deliberately carries no payload: the original
550
+ // verification material is not persisted (FR-056), so the backend
551
+ // re-checks by transaction against its own record of the purchase —
552
+ // which is why the transaction id has to travel with it.
553
+ verificationPayload: '',
554
+ correlationId: deps.nextCorrelationId(),
555
+ transactionId: record.transactionId,
556
+ platform: deps.platform,
557
+ });
558
+ approved = decision.approved;
559
+ } catch {
560
+ // Still unreachable. Leave it queued and try again next sync rather
561
+ // than revoking from a paying user because our backend is down.
562
+ continue;
563
+ }
564
+
565
+ const snapshot = deps.snapshot();
566
+ deps.commit({
567
+ ...snapshot,
568
+ purchases: snapshot.purchases.map(p =>
569
+ p.transactionId === record.transactionId
570
+ ? {
571
+ ...p,
572
+ validationState: approved ? 'approved' : 'rejected',
573
+ // A rejection on recovery revokes, which is the bound on the
574
+ // exposure `grant_and_queue` accepts (FR-072a).
575
+ state: approved ? p.state : 'revoked',
576
+ }
577
+ : p,
578
+ ),
579
+ });
580
+
581
+ deps.emit({
582
+ type: 'validation_decided',
583
+ productKey: record.productKey,
584
+ decision: approved ? 'approved' : 'rejected',
585
+ });
586
+ }
587
+ },
588
+ };
589
+ }