@chipmobilesdk/rn-payment 0.5.0 → 0.6.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 +61 -0
- package/README.md +46 -15
- package/package.json +1 -1
- package/skills/sdk-rn-payment/SKILL.md +4 -4
- package/src/adapter/iap-adapter.ts +12 -216
- package/src/adapter/iapShapes.ts +232 -0
- package/src/config/types.ts +8 -0
- package/src/config/validate.ts +19 -4
- package/src/core/retry.ts +1 -58
- package/src/diagnostics/events.ts +1 -1
- package/src/engine/connection.ts +194 -0
- package/src/engine/engine.ts +140 -700
- package/src/engine/internals.ts +40 -0
- package/src/engine/products.ts +39 -17
- package/src/engine/purchase.ts +22 -130
- package/src/engine/purchaseFailure.ts +48 -0
- package/src/engine/receiptValidation.ts +101 -0
- package/src/engine/state.ts +253 -0
- package/src/engine/sync.ts +161 -0
- package/src/entitlements/aggregate.ts +3 -5
- package/src/entitlements/mapping.ts +0 -22
- package/src/persistence/mmkv-store.ts +10 -3
- package/src/persistence/snapshot.ts +102 -27
- package/src/react/PaymentProvider.tsx +73 -24
- package/src/react/hooks.ts +13 -3
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What tests and diagnostics need from an engine, off its public type (PAY-11).
|
|
3
|
+
*
|
|
4
|
+
* Until 0.6.0 these were methods of `PaymentEngine`, so any consumer could commit
|
|
5
|
+
* a snapshot of its own and grant itself an entitlement the store never sold,
|
|
6
|
+
* and a hand-written engine double had to implement all of them. They live in a
|
|
7
|
+
* WeakMap keyed by the engine now, and this module is not exported from the
|
|
8
|
+
* package entrypoint.
|
|
9
|
+
*/
|
|
10
|
+
import type { ProductKey } from '../config/types';
|
|
11
|
+
import type { ResolvedConfig } from '../config/validate';
|
|
12
|
+
import type { PaymentEmitter } from '../diagnostics/events';
|
|
13
|
+
import type { PersistedSnapshot } from '../persistence/snapshot';
|
|
14
|
+
import type { PaymentEngine } from './engine';
|
|
15
|
+
|
|
16
|
+
export interface EngineInternals {
|
|
17
|
+
resolvedConfig(): ResolvedConfig;
|
|
18
|
+
nextCorrelationId(): string;
|
|
19
|
+
emitter(): PaymentEmitter;
|
|
20
|
+
snapshot(): PersistedSnapshot;
|
|
21
|
+
commitSnapshot(next: PersistedSnapshot): void;
|
|
22
|
+
mayCreateRealTransaction(): boolean;
|
|
23
|
+
/** Records a purchase refused because this environment may not move real money (FR-014). */
|
|
24
|
+
recordBlockedOperation(productKey: ProductKey): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const registry = new WeakMap<PaymentEngine, EngineInternals>();
|
|
28
|
+
|
|
29
|
+
export function attachEngineInternals(engine: PaymentEngine, internals: EngineInternals): void {
|
|
30
|
+
registry.set(engine, internals);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The internals of an engine `createEngine` made. Throws for any other object. */
|
|
34
|
+
export function engineInternals(engine: PaymentEngine): EngineInternals {
|
|
35
|
+
const internals = registry.get(engine);
|
|
36
|
+
if (!internals) {
|
|
37
|
+
throw new Error('engineInternals: this engine was not created by createEngine');
|
|
38
|
+
}
|
|
39
|
+
return internals;
|
|
40
|
+
}
|
package/src/engine/products.ts
CHANGED
|
@@ -188,22 +188,28 @@ export function createProducts(deps: ProductsDeps): Products {
|
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
try {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
191
|
+
// In parallel: a paywall offering a subscription and a one-time purchase
|
|
192
|
+
// used to wait for one store round-trip after the other (PAY-20).
|
|
193
|
+
const groups = await Promise.all(
|
|
194
|
+
[...byType].map(async ([productType, items]) => ({
|
|
195
|
+
items,
|
|
196
|
+
raw: await withTimeout(
|
|
197
|
+
deps.provider.queryProducts(
|
|
198
|
+
items.map(i => i.storeProductId),
|
|
199
|
+
productType,
|
|
200
|
+
),
|
|
201
|
+
resolved.timeouts.productQueryMs,
|
|
202
|
+
() =>
|
|
203
|
+
new PaymentError({
|
|
204
|
+
code: 'PAYMENT_TIMEOUT',
|
|
205
|
+
message: 'Querying products timed out',
|
|
206
|
+
operation: 'getProducts',
|
|
207
|
+
retryable: true,
|
|
208
|
+
}),
|
|
196
209
|
),
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
code: 'PAYMENT_TIMEOUT',
|
|
201
|
-
message: 'Querying products timed out',
|
|
202
|
-
operation: 'getProducts',
|
|
203
|
-
retryable: true,
|
|
204
|
-
}),
|
|
205
|
-
);
|
|
206
|
-
|
|
210
|
+
})),
|
|
211
|
+
);
|
|
212
|
+
for (const { items, raw } of groups) {
|
|
207
213
|
const returned = new Map(raw.map(p => [p.storeProductId, p]));
|
|
208
214
|
for (const item of items) {
|
|
209
215
|
const found = returned.get(item.storeProductId);
|
|
@@ -251,9 +257,25 @@ export function createProducts(deps: ProductsDeps): Products {
|
|
|
251
257
|
return { ok: true, products: resolvedProducts, missing, fromCache: false };
|
|
252
258
|
};
|
|
253
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Identical requests in flight share one store call (PAY-20): several screens
|
|
262
|
+
* mounting `useProducts` for the same paywall ask once.
|
|
263
|
+
*/
|
|
264
|
+
const flights = new Map<string, Promise<ProductQueryResult>>();
|
|
265
|
+
const joined = (productKeys: ProductKey[], useCache: boolean): Promise<ProductQueryResult> => {
|
|
266
|
+
const key = `${useCache ? 'get' : 'refresh'}:${productKeys.join('|')}`;
|
|
267
|
+
const running = flights.get(key);
|
|
268
|
+
if (running) {
|
|
269
|
+
return running;
|
|
270
|
+
}
|
|
271
|
+
const promise = query(productKeys, useCache).finally(() => flights.delete(key));
|
|
272
|
+
flights.set(key, promise);
|
|
273
|
+
return promise;
|
|
274
|
+
};
|
|
275
|
+
|
|
254
276
|
return {
|
|
255
|
-
get: productKeys =>
|
|
256
|
-
refresh: productKeys =>
|
|
277
|
+
get: productKeys => joined(productKeys, true),
|
|
278
|
+
refresh: productKeys => joined(productKeys, false),
|
|
257
279
|
invalidate: () => cache.clear(),
|
|
258
280
|
};
|
|
259
281
|
}
|
package/src/engine/purchase.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import type {
|
|
20
20
|
PaymentConfig,
|
|
21
|
+
PaymentStatusKind,
|
|
21
22
|
ProductKey,
|
|
22
23
|
PurchaseOptions,
|
|
23
24
|
PurchaseResult,
|
|
@@ -25,7 +26,6 @@ import type {
|
|
|
25
26
|
} from '../config/types';
|
|
26
27
|
import type { ResolvedConfig, TargetPlatform } from '../config/validate';
|
|
27
28
|
import type {
|
|
28
|
-
ProviderFailureReason,
|
|
29
29
|
ProviderPort,
|
|
30
30
|
ProviderPurchase,
|
|
31
31
|
ProviderPurchaseOutcome,
|
|
@@ -37,13 +37,10 @@ import { withTimeout } from '../core/mutex';
|
|
|
37
37
|
import { PURCHASE_FLOW_CEILING_MS } from '../core/timers';
|
|
38
38
|
import { PaymentError } from '../diagnostics/errors';
|
|
39
39
|
import type { PaymentEventInput } from '../diagnostics/events';
|
|
40
|
-
import {
|
|
41
|
-
resolveFinalizeState,
|
|
42
|
-
type PersistedPurchase,
|
|
43
|
-
type PersistedSnapshot,
|
|
44
|
-
} from '../persistence/snapshot';
|
|
40
|
+
import type { PersistedPurchase, PersistedSnapshot } from '../persistence/snapshot';
|
|
45
41
|
import { storeIdForProductKey } from '../entitlements/mapping';
|
|
46
42
|
import type { Finalizer } from './finalize';
|
|
43
|
+
import { toPersistedRecord } from './sync';
|
|
47
44
|
|
|
48
45
|
export interface PurchaseFlowDeps {
|
|
49
46
|
config: PaymentConfig;
|
|
@@ -56,41 +53,17 @@ export interface PurchaseFlowDeps {
|
|
|
56
53
|
emit: (event: PaymentEventInput) => void;
|
|
57
54
|
now: () => number;
|
|
58
55
|
nextCorrelationId: () => string;
|
|
59
|
-
statusKind: () =>
|
|
56
|
+
statusKind: () => PaymentStatusKind;
|
|
60
57
|
mayCreateRealTransaction: () => boolean;
|
|
61
58
|
recordBlockedOperation: (productKey: ProductKey) => void;
|
|
62
59
|
requestSync: (trigger: 'already_owned') => void;
|
|
63
60
|
runExclusive: <T>(operation: () => Promise<T>) => Promise<ExclusiveOutcome<T>>;
|
|
64
61
|
}
|
|
65
62
|
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
}
|
|
63
|
+
/** The error code each failed purchase kind is reported under (PAY-19). */
|
|
64
|
+
import { createReceiptValidation } from './receiptValidation';
|
|
65
|
+
import { FAILURE_CODES, mapFailure } from './purchaseFailure';
|
|
92
66
|
|
|
93
|
-
/** Plan-change parameters, which only `subscriptions.ts` supplies. */
|
|
94
67
|
export interface InternalPurchaseOptions extends PurchaseOptions {
|
|
95
68
|
replaceProductKey?: ProductKey;
|
|
96
69
|
prorationMode?: string;
|
|
@@ -113,6 +86,13 @@ export interface PurchaseFlow {
|
|
|
113
86
|
|
|
114
87
|
export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
115
88
|
const { resolved } = deps;
|
|
89
|
+
// The backend check and what to do when it cannot answer (receiptValidation).
|
|
90
|
+
const { askHook, validate } = createReceiptValidation({
|
|
91
|
+
config: deps.config,
|
|
92
|
+
platform: deps.platform,
|
|
93
|
+
resolved,
|
|
94
|
+
emit: deps.emit as never,
|
|
95
|
+
});
|
|
116
96
|
|
|
117
97
|
const toRef = (record: PersistedPurchase): TransactionRef => ({
|
|
118
98
|
transactionId: record.transactionId,
|
|
@@ -126,27 +106,13 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
|
126
106
|
deps.commit({ ...snapshot, purchases: [...others, record] });
|
|
127
107
|
};
|
|
128
108
|
|
|
129
|
-
const recordFor = (purchase: ProviderPurchase, productKey: ProductKey): PersistedPurchase =>
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
.purchases.find(p => p.transactionId === purchase.transactionId);
|
|
134
|
-
return {
|
|
135
|
-
transactionId: purchase.transactionId,
|
|
109
|
+
const recordFor = (purchase: ProviderPurchase, productKey: ProductKey): PersistedPurchase =>
|
|
110
|
+
toPersistedRecord(
|
|
111
|
+
resolved,
|
|
112
|
+
purchase,
|
|
136
113
|
productKey,
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
};
|
|
114
|
+
deps.snapshot().purchases.find(p => p.transactionId === purchase.transactionId),
|
|
115
|
+
);
|
|
150
116
|
|
|
151
117
|
/**
|
|
152
118
|
* Calls the app's hook under `timeouts.validateMs`.
|
|
@@ -155,71 +121,6 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
|
155
121
|
* revalidation, the sync flight for the rest of the session. A timeout rejects,
|
|
156
122
|
* so callers treat it exactly like a hook that throws: unavailable (FR-072b).
|
|
157
123
|
*/
|
|
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
124
|
/**
|
|
224
125
|
* Settlements in progress, by transaction.
|
|
225
126
|
*
|
|
@@ -434,7 +335,7 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
|
434
335
|
}
|
|
435
336
|
|
|
436
337
|
if (!outcome.ok) {
|
|
437
|
-
const mapped = mapFailure(outcome.reason, productKey);
|
|
338
|
+
const mapped = mapFailure(outcome.reason, productKey, deps.statusKind());
|
|
438
339
|
|
|
439
340
|
if (mapped.kind === 'user_cancelled') {
|
|
440
341
|
// A normal outcome. Reported on its own channel so it is never
|
|
@@ -452,16 +353,7 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
|
|
|
452
353
|
deps.emit({
|
|
453
354
|
type: 'purchase_failed',
|
|
454
355
|
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',
|
|
356
|
+
code: FAILURE_CODES[mapped.kind] ?? 'PAYMENT_STORE_ERROR',
|
|
465
357
|
retryable: mapped.kind === 'network_error',
|
|
466
358
|
correlationId,
|
|
467
359
|
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a refused purchase means to the app (PAY-19).
|
|
3
|
+
*
|
|
4
|
+
* The store answers with a reason of its own; this turns it into the result the
|
|
5
|
+
* app sees, under the code it can branch on, so no store vocabulary leaks out.
|
|
6
|
+
* Split from the flow next door, which is about the sequence of calls.
|
|
7
|
+
*/
|
|
8
|
+
import type { ProviderFailureReason } from '../adapter/port';
|
|
9
|
+
import type { PaymentStatusKind, ProductKey, PurchaseResult } from '../config/types';
|
|
10
|
+
import type { PaymentErrorCode } from '../diagnostics/errors';
|
|
11
|
+
|
|
12
|
+
export const FAILURE_CODES: Partial<Record<PurchaseResult['kind'], PaymentErrorCode>> = {
|
|
13
|
+
network_error: 'PAYMENT_NETWORK_ERROR',
|
|
14
|
+
unknown_product: 'PAYMENT_UNKNOWN_PRODUCT',
|
|
15
|
+
ineligible: 'PAYMENT_INELIGIBLE',
|
|
16
|
+
product_unavailable_in_region: 'PAYMENT_PRODUCT_UNAVAILABLE_IN_REGION',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Maps a provider failure onto the public vocabulary, so no store code leaks out. */
|
|
20
|
+
export function mapFailure(
|
|
21
|
+
reason: ProviderFailureReason,
|
|
22
|
+
productKey: ProductKey,
|
|
23
|
+
status: PaymentStatusKind,
|
|
24
|
+
): PurchaseResult {
|
|
25
|
+
switch (reason) {
|
|
26
|
+
case 'user_cancelled':
|
|
27
|
+
return { kind: 'user_cancelled', productKey };
|
|
28
|
+
case 'already_owned':
|
|
29
|
+
return { kind: 'already_owned', productKey };
|
|
30
|
+
case 'ineligible':
|
|
31
|
+
return { kind: 'ineligible', productKey };
|
|
32
|
+
case 'unknown_product':
|
|
33
|
+
return { kind: 'unknown_product', productKey };
|
|
34
|
+
case 'product_unavailable_in_region':
|
|
35
|
+
return { kind: 'product_unavailable_in_region', productKey };
|
|
36
|
+
case 'not_ready':
|
|
37
|
+
// The engine's real status, not a guess (PAY-19).
|
|
38
|
+
return { kind: 'not_ready', status };
|
|
39
|
+
case 'network_error':
|
|
40
|
+
return { kind: 'network_error', productKey };
|
|
41
|
+
case 'timeout':
|
|
42
|
+
return { kind: 'timeout', productKey };
|
|
43
|
+
case 'store_error':
|
|
44
|
+
return { kind: 'store_error', productKey, code: 'PAYMENT_STORE_ERROR' };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Plan-change parameters, which only `subscriptions.ts` supplies. */
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asking the app's backend whether a purchase is real (FR-069a, FR-072b).
|
|
3
|
+
*
|
|
4
|
+
* The hook is optional and bounded by a timeout, and its answer decides whether
|
|
5
|
+
* an entitlement is granted — so what happens when it throws, times out, or is
|
|
6
|
+
* absent is the whole point of this module. Split from the flow next door,
|
|
7
|
+
* which is about the sequence of store calls.
|
|
8
|
+
*
|
|
9
|
+
* A hook that could not answer is never an implicit approval: the configured
|
|
10
|
+
* `onValidationUnavailable` decides, and the default refuses.
|
|
11
|
+
*/
|
|
12
|
+
import type { ProviderPurchase } from '../adapter/port';
|
|
13
|
+
import type { PaymentConfig, ProductKey } from '../config/types';
|
|
14
|
+
import type { ResolvedConfig, TargetPlatform } from '../config/validate';
|
|
15
|
+
import type { PaymentEventInput } from '../diagnostics/events';
|
|
16
|
+
import { withTimeout } from '../core/mutex';
|
|
17
|
+
import { PaymentError } from '../diagnostics/errors';
|
|
18
|
+
|
|
19
|
+
export interface ReceiptValidationDeps {
|
|
20
|
+
config: PaymentConfig;
|
|
21
|
+
platform: TargetPlatform;
|
|
22
|
+
resolved: ResolvedConfig;
|
|
23
|
+
emit: (event: PaymentEventInput) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What the backend decided about one purchase. */
|
|
27
|
+
export type ValidationOutcome =
|
|
28
|
+
| { outcome: 'approved' }
|
|
29
|
+
| { outcome: 'rejected'; reason?: string }
|
|
30
|
+
| { outcome: 'granted_pending_revalidation' };
|
|
31
|
+
|
|
32
|
+
export function createReceiptValidation(deps: ReceiptValidationDeps) {
|
|
33
|
+
const { resolved } = deps;
|
|
34
|
+
|
|
35
|
+
const askHook = (
|
|
36
|
+
hook: NonNullable<PaymentConfig['validateTransaction']>,
|
|
37
|
+
candidate: Parameters<NonNullable<PaymentConfig['validateTransaction']>>[0],
|
|
38
|
+
) =>
|
|
39
|
+
withTimeout(
|
|
40
|
+
hook(candidate),
|
|
41
|
+
resolved.timeouts.validateMs,
|
|
42
|
+
() =>
|
|
43
|
+
new PaymentError({
|
|
44
|
+
code: 'PAYMENT_VALIDATION_UNAVAILABLE',
|
|
45
|
+
message: 'The validation hook did not answer in time',
|
|
46
|
+
operation: 'validate',
|
|
47
|
+
retryable: true,
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Runs the app's backend check, if configured.
|
|
53
|
+
*
|
|
54
|
+
* Called for newly completed purchases only (FR-069a) — sync and restore trust
|
|
55
|
+
* a decision already made, which is what keeps restore working while the
|
|
56
|
+
* backend is unreachable.
|
|
57
|
+
*/
|
|
58
|
+
const validate = async (
|
|
59
|
+
purchase: ProviderPurchase,
|
|
60
|
+
productKey: ProductKey,
|
|
61
|
+
correlationId: string,
|
|
62
|
+
): Promise<
|
|
63
|
+
| { outcome: 'approved' }
|
|
64
|
+
| { outcome: 'rejected'; reason?: string }
|
|
65
|
+
| { outcome: 'granted_pending_revalidation' }
|
|
66
|
+
> => {
|
|
67
|
+
const hook = deps.config.validateTransaction;
|
|
68
|
+
if (!hook) {
|
|
69
|
+
return { outcome: 'approved' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const entry = resolved.entries.get(productKey);
|
|
73
|
+
try {
|
|
74
|
+
const decision = await askHook(hook, {
|
|
75
|
+
productKey,
|
|
76
|
+
productType: entry?.productType ?? 'one_time_permanent',
|
|
77
|
+
verificationPayload: purchase.verificationPayload ?? '',
|
|
78
|
+
correlationId,
|
|
79
|
+
transactionId: purchase.transactionId,
|
|
80
|
+
platform: deps.platform,
|
|
81
|
+
});
|
|
82
|
+
deps.emit({
|
|
83
|
+
type: 'validation_decided',
|
|
84
|
+
productKey,
|
|
85
|
+
decision: decision.approved ? 'approved' : 'rejected',
|
|
86
|
+
});
|
|
87
|
+
return decision.approved
|
|
88
|
+
? { outcome: 'approved' }
|
|
89
|
+
: { outcome: 'rejected', reason: decision.reason };
|
|
90
|
+
} catch {
|
|
91
|
+
// A hook that throws is unavailable, never an implicit approval (FR-072b).
|
|
92
|
+
const fallback = resolved.onValidationUnavailable;
|
|
93
|
+
deps.emit({ type: 'validation_unavailable', productKey, fallback });
|
|
94
|
+
return fallback === 'grant_and_queue'
|
|
95
|
+
? { outcome: 'granted_pending_revalidation' }
|
|
96
|
+
: { outcome: 'rejected', reason: 'validation_unavailable' };
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return { askHook, validate };
|
|
101
|
+
}
|