@chipmobilesdk/rn-payment 0.1.0 → 0.2.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 +90 -1
- package/README.md +885 -761
- package/package.json +54 -54
- package/skills/sdk-rn-payment/SKILL.md +33 -6
- package/src/adapter/createStoreProvider.ts +117 -117
- package/src/adapter/iap-adapter.ts +547 -516
- package/src/adapter/port.ts +12 -0
- package/src/config/types.ts +40 -0
- package/src/diagnostics/events.ts +9 -0
- package/src/engine/engine.ts +951 -854
- package/src/engine/finalize.ts +298 -298
- package/src/engine/pending.ts +118 -0
- package/src/engine/products.ts +259 -259
- package/src/engine/purchase.ts +526 -495
- package/src/engine/subscriptions.ts +157 -157
- package/src/entitlements/aggregate.ts +21 -4
- package/src/index.ts +4 -0
- package/src/react/hooks.ts +274 -238
- package/src/testing/fakeStore.ts +33 -0
package/src/engine/purchase.ts
CHANGED
|
@@ -1,495 +1,526 @@
|
|
|
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 } from '../config/validate';
|
|
27
|
-
import type {
|
|
28
|
-
ProviderFailureReason,
|
|
29
|
-
ProviderPort,
|
|
30
|
-
ProviderPurchase,
|
|
31
|
-
} from '../adapter/port';
|
|
32
|
-
import type { ExclusiveOutcome } from '../core/mutex';
|
|
33
|
-
import { withTimeout } from '../core/mutex';
|
|
34
|
-
import { PaymentError } from '../diagnostics/errors';
|
|
35
|
-
import type { PaymentEventInput } from '../diagnostics/events';
|
|
36
|
-
import {
|
|
37
|
-
resolveFinalizeState,
|
|
38
|
-
type PersistedPurchase,
|
|
39
|
-
type PersistedSnapshot,
|
|
40
|
-
} from '../persistence/snapshot';
|
|
41
|
-
import { storeIdForProductKey } from '../entitlements/mapping';
|
|
42
|
-
import type { Finalizer } from './finalize';
|
|
43
|
-
|
|
44
|
-
export interface PurchaseFlowDeps {
|
|
45
|
-
config: PaymentConfig;
|
|
46
|
-
resolved: ResolvedConfig;
|
|
47
|
-
provider: ProviderPort;
|
|
48
|
-
finalizer: Finalizer;
|
|
49
|
-
snapshot: () => PersistedSnapshot;
|
|
50
|
-
commit: (next: PersistedSnapshot) => void;
|
|
51
|
-
emit: (event: PaymentEventInput) => void;
|
|
52
|
-
now: () => number;
|
|
53
|
-
nextCorrelationId: () => string;
|
|
54
|
-
statusKind: () => PaymentStatusKindLike;
|
|
55
|
-
mayCreateRealTransaction: () => boolean;
|
|
56
|
-
recordBlockedOperation: (productKey: ProductKey) => void;
|
|
57
|
-
requestSync: (trigger: 'already_owned') => void;
|
|
58
|
-
runExclusive: <T>(operation: () => Promise<T>) => Promise<ExclusiveOutcome<T>>;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
type PaymentStatusKindLike =
|
|
62
|
-
| 'not_started'
|
|
63
|
-
| 'connecting'
|
|
64
|
-
| 'ready'
|
|
65
|
-
| 'disabled'
|
|
66
|
-
| 'unavailable'
|
|
67
|
-
| 'failed';
|
|
68
|
-
|
|
69
|
-
/** Maps a provider failure onto the public vocabulary, so no store code leaks out. */
|
|
70
|
-
function mapFailure(
|
|
71
|
-
reason: ProviderFailureReason,
|
|
72
|
-
productKey: ProductKey,
|
|
73
|
-
): PurchaseResult {
|
|
74
|
-
switch (reason) {
|
|
75
|
-
case 'user_cancelled':
|
|
76
|
-
return { kind: 'user_cancelled', productKey };
|
|
77
|
-
case 'already_owned':
|
|
78
|
-
return { kind: 'already_owned', productKey };
|
|
79
|
-
case 'ineligible':
|
|
80
|
-
return { kind: 'ineligible', productKey };
|
|
81
|
-
case 'unknown_product':
|
|
82
|
-
return { kind: 'unknown_product', productKey };
|
|
83
|
-
case 'product_unavailable_in_region':
|
|
84
|
-
return { kind: 'product_unavailable_in_region', productKey };
|
|
85
|
-
case 'not_ready':
|
|
86
|
-
return { kind: 'not_ready', status: 'connecting' };
|
|
87
|
-
case 'network_error':
|
|
88
|
-
return { kind: 'network_error', productKey };
|
|
89
|
-
case 'timeout':
|
|
90
|
-
return { kind: 'timeout', productKey };
|
|
91
|
-
case 'store_error':
|
|
92
|
-
return { kind: 'store_error', productKey, code: 'PAYMENT_STORE_ERROR' };
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Plan-change parameters, which only `subscriptions.ts` supplies. */
|
|
97
|
-
export interface InternalPurchaseOptions extends PurchaseOptions {
|
|
98
|
-
replaceProductKey?: ProductKey;
|
|
99
|
-
prorationMode?: string;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export interface PurchaseFlow {
|
|
103
|
-
purchase(
|
|
104
|
-
productKey: ProductKey,
|
|
105
|
-
options?: InternalPurchaseOptions,
|
|
106
|
-
): Promise<PurchaseResult>;
|
|
107
|
-
/** Handles a store-pushed update, e.g. a pending purchase settling (FR-039). */
|
|
108
|
-
ingest(purchase: ProviderPurchase): Promise<void>;
|
|
109
|
-
/**
|
|
110
|
-
* Re-runs the backend check for transactions granted under the
|
|
111
|
-
* failure-fallback (FR-072a).
|
|
112
|
-
*
|
|
113
|
-
* Called on sync, so recovery happens on the app's own rhythm rather than on a
|
|
114
|
-
* timer of ours. A rejection here revokes, which is what bounds the exposure
|
|
115
|
-
* that `grant_and_queue` accepts.
|
|
116
|
-
*/
|
|
117
|
-
revalidate(): Promise<void>;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
121
|
-
const { resolved } = deps;
|
|
122
|
-
|
|
123
|
-
const toRef = (record: PersistedPurchase): TransactionRef => ({
|
|
124
|
-
transactionId: record.transactionId,
|
|
125
|
-
productKey: record.productKey,
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
/** Writes or replaces the record for one transaction. */
|
|
129
|
-
const upsert = (record: PersistedPurchase): void => {
|
|
130
|
-
const snapshot = deps.snapshot();
|
|
131
|
-
const others = snapshot.purchases.filter(
|
|
132
|
-
p => p.transactionId !== record.transactionId,
|
|
133
|
-
);
|
|
134
|
-
deps.commit({ ...snapshot, purchases: [...others, record] });
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
const recordFor = (
|
|
138
|
-
purchase: ProviderPurchase,
|
|
139
|
-
productKey: ProductKey,
|
|
140
|
-
): PersistedPurchase => {
|
|
141
|
-
const entry = resolved.entries.get(productKey);
|
|
142
|
-
const existing = deps
|
|
143
|
-
.snapshot()
|
|
144
|
-
.purchases.find(p => p.transactionId === purchase.transactionId);
|
|
145
|
-
return {
|
|
146
|
-
transactionId: purchase.transactionId,
|
|
147
|
-
productKey,
|
|
148
|
-
productType: entry?.productType ?? 'one_time_permanent',
|
|
149
|
-
token: purchase.token,
|
|
150
|
-
state: purchase.state === 'pending' ? 'pending' : 'purchased',
|
|
151
|
-
purchasedAt: purchase.purchasedAt,
|
|
152
|
-
// Shared with the sync path so the two cannot disagree about what is still
|
|
153
|
-
// owed a finalize call.
|
|
154
|
-
finalizeState: resolveFinalizeState(purchase, existing),
|
|
155
|
-
attempts: existing?.attempts ?? 0,
|
|
156
|
-
validationState: existing?.validationState ?? 'not_required',
|
|
157
|
-
expiresAt: purchase.expiresAt,
|
|
158
|
-
willAutoRenew: purchase.willAutoRenew,
|
|
159
|
-
};
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Runs the app's backend check, if configured.
|
|
164
|
-
*
|
|
165
|
-
* Called for newly completed purchases only (FR-069a) — sync and restore trust
|
|
166
|
-
* a decision already made, which is what keeps restore working while the
|
|
167
|
-
* backend is unreachable.
|
|
168
|
-
*/
|
|
169
|
-
const validate = async (
|
|
170
|
-
purchase: ProviderPurchase,
|
|
171
|
-
productKey: ProductKey,
|
|
172
|
-
correlationId: string,
|
|
173
|
-
): Promise<
|
|
174
|
-
| { outcome: 'approved' }
|
|
175
|
-
| { outcome: 'rejected'; reason?: string }
|
|
176
|
-
| { outcome: 'granted_pending_revalidation' }
|
|
177
|
-
> => {
|
|
178
|
-
const hook = deps.config.validateTransaction;
|
|
179
|
-
if (!hook) {
|
|
180
|
-
return { outcome: 'approved' };
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const entry = resolved.entries.get(productKey);
|
|
184
|
-
try {
|
|
185
|
-
const decision = await hook({
|
|
186
|
-
productKey,
|
|
187
|
-
productType: entry?.productType ?? 'one_time_permanent',
|
|
188
|
-
verificationPayload: purchase.verificationPayload ?? '',
|
|
189
|
-
correlationId,
|
|
190
|
-
});
|
|
191
|
-
deps.emit({
|
|
192
|
-
type: 'validation_decided',
|
|
193
|
-
productKey,
|
|
194
|
-
decision: decision.approved ? 'approved' : 'rejected',
|
|
195
|
-
});
|
|
196
|
-
return decision.approved
|
|
197
|
-
? { outcome: 'approved' }
|
|
198
|
-
: { outcome: 'rejected', reason: decision.reason };
|
|
199
|
-
} catch {
|
|
200
|
-
// A hook that throws is unavailable, never an implicit approval (FR-072b).
|
|
201
|
-
const fallback = resolved.onValidationUnavailable;
|
|
202
|
-
deps.emit({ type: 'validation_unavailable', productKey, fallback });
|
|
203
|
-
return fallback === 'grant_and_queue'
|
|
204
|
-
? { outcome: 'granted_pending_revalidation' }
|
|
205
|
-
: { outcome: 'rejected', reason: 'validation_unavailable' };
|
|
206
|
-
}
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
/** Persists a completed purchase, grants it, then finalizes. */
|
|
210
|
-
const settle = async (
|
|
211
|
-
purchase: ProviderPurchase,
|
|
212
|
-
productKey: ProductKey,
|
|
213
|
-
correlationId: string,
|
|
214
|
-
): Promise<PurchaseResult> => {
|
|
215
|
-
const decision = await validate(purchase, productKey, correlationId);
|
|
216
|
-
|
|
217
|
-
if (decision.outcome === 'rejected') {
|
|
218
|
-
upsert({
|
|
219
|
-
...recordFor(purchase, productKey),
|
|
220
|
-
validationState: 'rejected',
|
|
221
|
-
finalizeState: 'queued',
|
|
222
|
-
});
|
|
223
|
-
// Still finalized with the store: the money moved, so leaving it
|
|
224
|
-
// unacknowledged would have Google refund it while our backend has
|
|
225
|
-
// already decided the user is not entitled. Refunding is the app owner's
|
|
226
|
-
// call, not a side effect of a bookkeeping omission.
|
|
227
|
-
await deps.finalizer.enqueue(purchase.transactionId);
|
|
228
|
-
return {
|
|
229
|
-
kind: 'validation_rejected',
|
|
230
|
-
productKey,
|
|
231
|
-
reason: decision.reason,
|
|
232
|
-
};
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
upsert({
|
|
236
|
-
...recordFor(purchase, productKey),
|
|
237
|
-
validationState:
|
|
238
|
-
decision.outcome === 'granted_pending_revalidation'
|
|
239
|
-
? 'queued_for_revalidation'
|
|
240
|
-
: 'approved',
|
|
241
|
-
finalizeState: 'queued',
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
// Grant is already live at this point: `commit` recomputed entitlements.
|
|
245
|
-
// Finalize second, so a store hiccup cannot withhold what was paid for.
|
|
246
|
-
await deps.finalizer.enqueue(purchase.transactionId);
|
|
247
|
-
|
|
248
|
-
const record = deps
|
|
249
|
-
.snapshot()
|
|
250
|
-
.purchases.find(p => p.transactionId === purchase.transactionId);
|
|
251
|
-
return {
|
|
252
|
-
kind: 'success',
|
|
253
|
-
productKey,
|
|
254
|
-
transaction: record
|
|
255
|
-
? toRef(record)
|
|
256
|
-
: { transactionId: purchase.transactionId, productKey },
|
|
257
|
-
};
|
|
258
|
-
};
|
|
259
|
-
|
|
260
|
-
const handlePending = (
|
|
261
|
-
purchase: ProviderPurchase,
|
|
262
|
-
productKey: ProductKey,
|
|
263
|
-
): PurchaseResult => {
|
|
264
|
-
upsert(recordFor(purchase, productKey));
|
|
265
|
-
deps.emit({ type: 'purchase_pending', productKey });
|
|
266
|
-
return {
|
|
267
|
-
kind: 'pending',
|
|
268
|
-
productKey,
|
|
269
|
-
transaction: { transactionId: purchase.transactionId, productKey },
|
|
270
|
-
};
|
|
271
|
-
};
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
return {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
return;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
async
|
|
442
|
-
const
|
|
443
|
-
if (!
|
|
444
|
-
return;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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 } from '../config/validate';
|
|
27
|
+
import type {
|
|
28
|
+
ProviderFailureReason,
|
|
29
|
+
ProviderPort,
|
|
30
|
+
ProviderPurchase,
|
|
31
|
+
} from '../adapter/port';
|
|
32
|
+
import type { ExclusiveOutcome } from '../core/mutex';
|
|
33
|
+
import { withTimeout } from '../core/mutex';
|
|
34
|
+
import { PaymentError } from '../diagnostics/errors';
|
|
35
|
+
import type { PaymentEventInput } from '../diagnostics/events';
|
|
36
|
+
import {
|
|
37
|
+
resolveFinalizeState,
|
|
38
|
+
type PersistedPurchase,
|
|
39
|
+
type PersistedSnapshot,
|
|
40
|
+
} from '../persistence/snapshot';
|
|
41
|
+
import { storeIdForProductKey } from '../entitlements/mapping';
|
|
42
|
+
import type { Finalizer } from './finalize';
|
|
43
|
+
|
|
44
|
+
export interface PurchaseFlowDeps {
|
|
45
|
+
config: PaymentConfig;
|
|
46
|
+
resolved: ResolvedConfig;
|
|
47
|
+
provider: ProviderPort;
|
|
48
|
+
finalizer: Finalizer;
|
|
49
|
+
snapshot: () => PersistedSnapshot;
|
|
50
|
+
commit: (next: PersistedSnapshot) => void;
|
|
51
|
+
emit: (event: PaymentEventInput) => void;
|
|
52
|
+
now: () => number;
|
|
53
|
+
nextCorrelationId: () => string;
|
|
54
|
+
statusKind: () => PaymentStatusKindLike;
|
|
55
|
+
mayCreateRealTransaction: () => boolean;
|
|
56
|
+
recordBlockedOperation: (productKey: ProductKey) => void;
|
|
57
|
+
requestSync: (trigger: 'already_owned') => void;
|
|
58
|
+
runExclusive: <T>(operation: () => Promise<T>) => Promise<ExclusiveOutcome<T>>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type PaymentStatusKindLike =
|
|
62
|
+
| 'not_started'
|
|
63
|
+
| 'connecting'
|
|
64
|
+
| 'ready'
|
|
65
|
+
| 'disabled'
|
|
66
|
+
| 'unavailable'
|
|
67
|
+
| 'failed';
|
|
68
|
+
|
|
69
|
+
/** Maps a provider failure onto the public vocabulary, so no store code leaks out. */
|
|
70
|
+
function mapFailure(
|
|
71
|
+
reason: ProviderFailureReason,
|
|
72
|
+
productKey: ProductKey,
|
|
73
|
+
): PurchaseResult {
|
|
74
|
+
switch (reason) {
|
|
75
|
+
case 'user_cancelled':
|
|
76
|
+
return { kind: 'user_cancelled', productKey };
|
|
77
|
+
case 'already_owned':
|
|
78
|
+
return { kind: 'already_owned', productKey };
|
|
79
|
+
case 'ineligible':
|
|
80
|
+
return { kind: 'ineligible', productKey };
|
|
81
|
+
case 'unknown_product':
|
|
82
|
+
return { kind: 'unknown_product', productKey };
|
|
83
|
+
case 'product_unavailable_in_region':
|
|
84
|
+
return { kind: 'product_unavailable_in_region', productKey };
|
|
85
|
+
case 'not_ready':
|
|
86
|
+
return { kind: 'not_ready', status: 'connecting' };
|
|
87
|
+
case 'network_error':
|
|
88
|
+
return { kind: 'network_error', productKey };
|
|
89
|
+
case 'timeout':
|
|
90
|
+
return { kind: 'timeout', productKey };
|
|
91
|
+
case 'store_error':
|
|
92
|
+
return { kind: 'store_error', productKey, code: 'PAYMENT_STORE_ERROR' };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Plan-change parameters, which only `subscriptions.ts` supplies. */
|
|
97
|
+
export interface InternalPurchaseOptions extends PurchaseOptions {
|
|
98
|
+
replaceProductKey?: ProductKey;
|
|
99
|
+
prorationMode?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface PurchaseFlow {
|
|
103
|
+
purchase(
|
|
104
|
+
productKey: ProductKey,
|
|
105
|
+
options?: InternalPurchaseOptions,
|
|
106
|
+
): Promise<PurchaseResult>;
|
|
107
|
+
/** Handles a store-pushed update, e.g. a pending purchase settling (FR-039). */
|
|
108
|
+
ingest(purchase: ProviderPurchase): Promise<void>;
|
|
109
|
+
/**
|
|
110
|
+
* Re-runs the backend check for transactions granted under the
|
|
111
|
+
* failure-fallback (FR-072a).
|
|
112
|
+
*
|
|
113
|
+
* Called on sync, so recovery happens on the app's own rhythm rather than on a
|
|
114
|
+
* timer of ours. A rejection here revokes, which is what bounds the exposure
|
|
115
|
+
* that `grant_and_queue` accepts.
|
|
116
|
+
*/
|
|
117
|
+
revalidate(): Promise<void>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
121
|
+
const { resolved } = deps;
|
|
122
|
+
|
|
123
|
+
const toRef = (record: PersistedPurchase): TransactionRef => ({
|
|
124
|
+
transactionId: record.transactionId,
|
|
125
|
+
productKey: record.productKey,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/** Writes or replaces the record for one transaction. */
|
|
129
|
+
const upsert = (record: PersistedPurchase): void => {
|
|
130
|
+
const snapshot = deps.snapshot();
|
|
131
|
+
const others = snapshot.purchases.filter(
|
|
132
|
+
p => p.transactionId !== record.transactionId,
|
|
133
|
+
);
|
|
134
|
+
deps.commit({ ...snapshot, purchases: [...others, record] });
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const recordFor = (
|
|
138
|
+
purchase: ProviderPurchase,
|
|
139
|
+
productKey: ProductKey,
|
|
140
|
+
): PersistedPurchase => {
|
|
141
|
+
const entry = resolved.entries.get(productKey);
|
|
142
|
+
const existing = deps
|
|
143
|
+
.snapshot()
|
|
144
|
+
.purchases.find(p => p.transactionId === purchase.transactionId);
|
|
145
|
+
return {
|
|
146
|
+
transactionId: purchase.transactionId,
|
|
147
|
+
productKey,
|
|
148
|
+
productType: entry?.productType ?? 'one_time_permanent',
|
|
149
|
+
token: purchase.token,
|
|
150
|
+
state: purchase.state === 'pending' ? 'pending' : 'purchased',
|
|
151
|
+
purchasedAt: purchase.purchasedAt,
|
|
152
|
+
// Shared with the sync path so the two cannot disagree about what is still
|
|
153
|
+
// owed a finalize call.
|
|
154
|
+
finalizeState: resolveFinalizeState(purchase, existing),
|
|
155
|
+
attempts: existing?.attempts ?? 0,
|
|
156
|
+
validationState: existing?.validationState ?? 'not_required',
|
|
157
|
+
expiresAt: purchase.expiresAt,
|
|
158
|
+
willAutoRenew: purchase.willAutoRenew,
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Runs the app's backend check, if configured.
|
|
164
|
+
*
|
|
165
|
+
* Called for newly completed purchases only (FR-069a) — sync and restore trust
|
|
166
|
+
* a decision already made, which is what keeps restore working while the
|
|
167
|
+
* backend is unreachable.
|
|
168
|
+
*/
|
|
169
|
+
const validate = async (
|
|
170
|
+
purchase: ProviderPurchase,
|
|
171
|
+
productKey: ProductKey,
|
|
172
|
+
correlationId: string,
|
|
173
|
+
): Promise<
|
|
174
|
+
| { outcome: 'approved' }
|
|
175
|
+
| { outcome: 'rejected'; reason?: string }
|
|
176
|
+
| { outcome: 'granted_pending_revalidation' }
|
|
177
|
+
> => {
|
|
178
|
+
const hook = deps.config.validateTransaction;
|
|
179
|
+
if (!hook) {
|
|
180
|
+
return { outcome: 'approved' };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const entry = resolved.entries.get(productKey);
|
|
184
|
+
try {
|
|
185
|
+
const decision = await hook({
|
|
186
|
+
productKey,
|
|
187
|
+
productType: entry?.productType ?? 'one_time_permanent',
|
|
188
|
+
verificationPayload: purchase.verificationPayload ?? '',
|
|
189
|
+
correlationId,
|
|
190
|
+
});
|
|
191
|
+
deps.emit({
|
|
192
|
+
type: 'validation_decided',
|
|
193
|
+
productKey,
|
|
194
|
+
decision: decision.approved ? 'approved' : 'rejected',
|
|
195
|
+
});
|
|
196
|
+
return decision.approved
|
|
197
|
+
? { outcome: 'approved' }
|
|
198
|
+
: { outcome: 'rejected', reason: decision.reason };
|
|
199
|
+
} catch {
|
|
200
|
+
// A hook that throws is unavailable, never an implicit approval (FR-072b).
|
|
201
|
+
const fallback = resolved.onValidationUnavailable;
|
|
202
|
+
deps.emit({ type: 'validation_unavailable', productKey, fallback });
|
|
203
|
+
return fallback === 'grant_and_queue'
|
|
204
|
+
? { outcome: 'granted_pending_revalidation' }
|
|
205
|
+
: { outcome: 'rejected', reason: 'validation_unavailable' };
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** Persists a completed purchase, grants it, then finalizes. */
|
|
210
|
+
const settle = async (
|
|
211
|
+
purchase: ProviderPurchase,
|
|
212
|
+
productKey: ProductKey,
|
|
213
|
+
correlationId: string,
|
|
214
|
+
): Promise<PurchaseResult> => {
|
|
215
|
+
const decision = await validate(purchase, productKey, correlationId);
|
|
216
|
+
|
|
217
|
+
if (decision.outcome === 'rejected') {
|
|
218
|
+
upsert({
|
|
219
|
+
...recordFor(purchase, productKey),
|
|
220
|
+
validationState: 'rejected',
|
|
221
|
+
finalizeState: 'queued',
|
|
222
|
+
});
|
|
223
|
+
// Still finalized with the store: the money moved, so leaving it
|
|
224
|
+
// unacknowledged would have Google refund it while our backend has
|
|
225
|
+
// already decided the user is not entitled. Refunding is the app owner's
|
|
226
|
+
// call, not a side effect of a bookkeeping omission.
|
|
227
|
+
await deps.finalizer.enqueue(purchase.transactionId);
|
|
228
|
+
return {
|
|
229
|
+
kind: 'validation_rejected',
|
|
230
|
+
productKey,
|
|
231
|
+
reason: decision.reason,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
upsert({
|
|
236
|
+
...recordFor(purchase, productKey),
|
|
237
|
+
validationState:
|
|
238
|
+
decision.outcome === 'granted_pending_revalidation'
|
|
239
|
+
? 'queued_for_revalidation'
|
|
240
|
+
: 'approved',
|
|
241
|
+
finalizeState: 'queued',
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Grant is already live at this point: `commit` recomputed entitlements.
|
|
245
|
+
// Finalize second, so a store hiccup cannot withhold what was paid for.
|
|
246
|
+
await deps.finalizer.enqueue(purchase.transactionId);
|
|
247
|
+
|
|
248
|
+
const record = deps
|
|
249
|
+
.snapshot()
|
|
250
|
+
.purchases.find(p => p.transactionId === purchase.transactionId);
|
|
251
|
+
return {
|
|
252
|
+
kind: 'success',
|
|
253
|
+
productKey,
|
|
254
|
+
transaction: record
|
|
255
|
+
? toRef(record)
|
|
256
|
+
: { transactionId: purchase.transactionId, productKey },
|
|
257
|
+
};
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const handlePending = (
|
|
261
|
+
purchase: ProviderPurchase,
|
|
262
|
+
productKey: ProductKey,
|
|
263
|
+
): PurchaseResult => {
|
|
264
|
+
upsert(recordFor(purchase, productKey));
|
|
265
|
+
deps.emit({ type: 'purchase_pending', productKey });
|
|
266
|
+
return {
|
|
267
|
+
kind: 'pending',
|
|
268
|
+
productKey,
|
|
269
|
+
transaction: { transactionId: purchase.transactionId, productKey },
|
|
270
|
+
};
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The store returned a purchase in a state it could not name — neither paid
|
|
275
|
+
* nor pending. Nothing is granted, persisted or acknowledged on it.
|
|
276
|
+
*
|
|
277
|
+
* Retrying is the repair path: if the money did move, the store answers
|
|
278
|
+
* `already_owned`, which re-syncs and grants (FR-033).
|
|
279
|
+
*/
|
|
280
|
+
const handleUnspecified = (
|
|
281
|
+
productKey: ProductKey,
|
|
282
|
+
correlationId: string,
|
|
283
|
+
): PurchaseResult => {
|
|
284
|
+
deps.emit({
|
|
285
|
+
type: 'purchase_failed',
|
|
286
|
+
productKey,
|
|
287
|
+
code: 'PAYMENT_STORE_ERROR',
|
|
288
|
+
retryable: true,
|
|
289
|
+
correlationId,
|
|
290
|
+
});
|
|
291
|
+
return { kind: 'store_error', productKey, code: 'PAYMENT_STORE_ERROR' };
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
async purchase(
|
|
296
|
+
productKey: ProductKey,
|
|
297
|
+
options?: InternalPurchaseOptions,
|
|
298
|
+
): Promise<PurchaseResult> {
|
|
299
|
+
if (!resolved.enabled) {
|
|
300
|
+
return { kind: 'disabled' };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const status = deps.statusKind();
|
|
304
|
+
if (status !== 'ready') {
|
|
305
|
+
return { kind: 'not_ready', status };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const storeProductId = storeIdForProductKey(resolved, productKey);
|
|
309
|
+
if (!storeProductId) {
|
|
310
|
+
return { kind: 'unknown_product', productKey };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// The block that makes a developer mistake unable to move real money.
|
|
314
|
+
// Gated on the provider actually being one that would: the fake store
|
|
315
|
+
// must still be able to complete a purchase in a test (FR-014).
|
|
316
|
+
if (
|
|
317
|
+
deps.provider.capabilities().createsRealTransactions &&
|
|
318
|
+
!deps.mayCreateRealTransaction()
|
|
319
|
+
) {
|
|
320
|
+
deps.recordBlockedOperation(productKey);
|
|
321
|
+
return {
|
|
322
|
+
kind: 'environment_blocked',
|
|
323
|
+
productKey,
|
|
324
|
+
environment: deps.config.environment,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const entry = resolved.entries.get(productKey);
|
|
329
|
+
const correlationId = deps.nextCorrelationId();
|
|
330
|
+
|
|
331
|
+
// Exclusive, not single-flight: a second purchase is refused rather than
|
|
332
|
+
// joined or queued, so no second store surface opens (FR-031).
|
|
333
|
+
const attempt = await deps.runExclusive(async () => {
|
|
334
|
+
deps.emit({ type: 'purchase_started', productKey, correlationId });
|
|
335
|
+
|
|
336
|
+
let outcome;
|
|
337
|
+
try {
|
|
338
|
+
outcome = await withTimeout(
|
|
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
|
+
);
|
|
369
|
+
} catch {
|
|
370
|
+
deps.emit({
|
|
371
|
+
type: 'purchase_failed',
|
|
372
|
+
productKey,
|
|
373
|
+
code: 'PAYMENT_TIMEOUT',
|
|
374
|
+
retryable: true,
|
|
375
|
+
correlationId,
|
|
376
|
+
});
|
|
377
|
+
return { kind: 'timeout', productKey } as PurchaseResult;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (!outcome.ok) {
|
|
381
|
+
const mapped = mapFailure(outcome.reason, productKey);
|
|
382
|
+
|
|
383
|
+
if (mapped.kind === 'user_cancelled') {
|
|
384
|
+
// A normal outcome. Reported on its own channel so it is never
|
|
385
|
+
// counted as a technical failure (FR-032).
|
|
386
|
+
deps.emit({ type: 'purchase_cancelled', productKey, correlationId });
|
|
387
|
+
return mapped;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (mapped.kind === 'already_owned') {
|
|
391
|
+
// How a reinstall or a new device repairs itself (FR-033).
|
|
392
|
+
deps.requestSync('already_owned');
|
|
393
|
+
return mapped;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
deps.emit({
|
|
397
|
+
type: 'purchase_failed',
|
|
398
|
+
productKey,
|
|
399
|
+
code:
|
|
400
|
+
mapped.kind === 'network_error'
|
|
401
|
+
? 'PAYMENT_NETWORK_ERROR'
|
|
402
|
+
: mapped.kind === 'unknown_product'
|
|
403
|
+
? 'PAYMENT_UNKNOWN_PRODUCT'
|
|
404
|
+
: mapped.kind === 'ineligible'
|
|
405
|
+
? 'PAYMENT_INELIGIBLE'
|
|
406
|
+
: mapped.kind === 'product_unavailable_in_region'
|
|
407
|
+
? 'PAYMENT_PRODUCT_UNAVAILABLE_IN_REGION'
|
|
408
|
+
: 'PAYMENT_STORE_ERROR',
|
|
409
|
+
retryable: mapped.kind === 'network_error',
|
|
410
|
+
correlationId,
|
|
411
|
+
});
|
|
412
|
+
return mapped;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (outcome.purchase.state === 'pending') {
|
|
416
|
+
return handlePending(outcome.purchase, productKey);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (outcome.purchase.state === 'unspecified') {
|
|
420
|
+
return handleUnspecified(productKey, correlationId);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
deps.emit({ type: 'purchase_succeeded', productKey, correlationId });
|
|
424
|
+
return settle(outcome.purchase, productKey, correlationId);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
if (!attempt.acquired) {
|
|
428
|
+
return { kind: 'purchase_in_progress' };
|
|
429
|
+
}
|
|
430
|
+
return attempt.value;
|
|
431
|
+
},
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* A store-pushed update.
|
|
435
|
+
*
|
|
436
|
+
* This is how a pending purchase that settled between sessions is granted
|
|
437
|
+
* without the user buying again (FR-039). It runs outside the exclusive lock
|
|
438
|
+
* because it is not a user-initiated purchase and must not be refused just
|
|
439
|
+
* because one is open.
|
|
440
|
+
*/
|
|
441
|
+
async ingest(purchase: ProviderPurchase): Promise<void> {
|
|
442
|
+
const productKey = resolved.byStoreId.get(purchase.storeProductId);
|
|
443
|
+
if (!productKey) {
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (purchase.state === 'pending') {
|
|
448
|
+
handlePending(purchase, productKey);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (purchase.state === 'unspecified') {
|
|
453
|
+
// No basis to grant or to end anything. The next sync reports what
|
|
454
|
+
// the store actually holds.
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const existing = deps
|
|
459
|
+
.snapshot()
|
|
460
|
+
.purchases.find(p => p.transactionId === purchase.transactionId);
|
|
461
|
+
|
|
462
|
+
// Already settled and validated: nothing to redo, and re-running the hook
|
|
463
|
+
// here would break FR-069a's "new purchases only" rule.
|
|
464
|
+
if (existing && existing.state === 'purchased' && existing.validationState !== 'not_required') {
|
|
465
|
+
await deps.finalizer.enqueue(purchase.transactionId);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
await settle(purchase, productKey, deps.nextCorrelationId());
|
|
470
|
+
},
|
|
471
|
+
|
|
472
|
+
async revalidate(): Promise<void> {
|
|
473
|
+
const hook = deps.config.validateTransaction;
|
|
474
|
+
if (!hook) {
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const queued = deps
|
|
479
|
+
.snapshot()
|
|
480
|
+
.purchases.filter(p => p.validationState === 'queued_for_revalidation');
|
|
481
|
+
|
|
482
|
+
for (const record of queued) {
|
|
483
|
+
const entry = resolved.entries.get(record.productKey);
|
|
484
|
+
let approved: boolean;
|
|
485
|
+
try {
|
|
486
|
+
const decision = await hook({
|
|
487
|
+
productKey: record.productKey,
|
|
488
|
+
productType: entry?.productType ?? 'one_time_permanent',
|
|
489
|
+
// Re-validation deliberately carries no payload: the original
|
|
490
|
+
// verification material is not persisted (FR-056), so the backend
|
|
491
|
+
// re-checks by transaction against its own record of the purchase.
|
|
492
|
+
verificationPayload: '',
|
|
493
|
+
correlationId: deps.nextCorrelationId(),
|
|
494
|
+
});
|
|
495
|
+
approved = decision.approved;
|
|
496
|
+
} catch {
|
|
497
|
+
// Still unreachable. Leave it queued and try again next sync rather
|
|
498
|
+
// than revoking from a paying user because our backend is down.
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const snapshot = deps.snapshot();
|
|
503
|
+
deps.commit({
|
|
504
|
+
...snapshot,
|
|
505
|
+
purchases: snapshot.purchases.map(p =>
|
|
506
|
+
p.transactionId === record.transactionId
|
|
507
|
+
? {
|
|
508
|
+
...p,
|
|
509
|
+
validationState: approved ? 'approved' : 'rejected',
|
|
510
|
+
// A rejection on recovery revokes, which is the bound on the
|
|
511
|
+
// exposure `grant_and_queue` accepts (FR-072a).
|
|
512
|
+
state: approved ? p.state : 'revoked',
|
|
513
|
+
}
|
|
514
|
+
: p,
|
|
515
|
+
),
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
deps.emit({
|
|
519
|
+
type: 'validation_decided',
|
|
520
|
+
productKey: record.productKey,
|
|
521
|
+
decision: approved ? 'approved' : 'rejected',
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
},
|
|
525
|
+
};
|
|
526
|
+
}
|