@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.
@@ -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
+ }
@@ -188,22 +188,28 @@ export function createProducts(deps: ProductsDeps): Products {
188
188
  }
189
189
 
190
190
  try {
191
- for (const [productType, items] of byType) {
192
- const raw = await withTimeout(
193
- deps.provider.queryProducts(
194
- items.map(i => i.storeProductId),
195
- productType,
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
- resolved.timeouts.productQueryMs,
198
- () =>
199
- new PaymentError({
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 => query(productKeys, true),
256
- refresh: productKeys => query(productKeys, false),
277
+ get: productKeys => joined(productKeys, true),
278
+ refresh: productKeys => joined(productKeys, false),
257
279
  invalidate: () => cache.clear(),
258
280
  };
259
281
  }
@@ -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: () => PaymentStatusKindLike;
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
- 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
- }
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
- 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,
109
+ const recordFor = (purchase: ProviderPurchase, productKey: ProductKey): PersistedPurchase =>
110
+ toPersistedRecord(
111
+ resolved,
112
+ purchase,
136
113
  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
- };
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
+ }