@chipmobilesdk/rn-payment 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,6 +25,8 @@ import type {
25
25
  import type { ProviderPort } from '../adapter/port';
26
26
  import type { ResolvedRetry } from '../core/timers';
27
27
  import { backoffDelayMs } from '../core/retry';
28
+ import { withTimeout } from '../core/mutex';
29
+ import { PaymentError } from '../diagnostics/errors';
28
30
  import {
29
31
  pendingFinalizations,
30
32
  type PersistedPurchase,
@@ -35,6 +37,8 @@ import type { PaymentEventInput } from '../diagnostics/events';
35
37
  export interface FinalizerDeps {
36
38
  provider: ProviderPort;
37
39
  retry: ResolvedRetry;
40
+ /** `timeouts.finalizeAttemptMs`: one acknowledge or consume call may take no longer. */
41
+ attemptTimeoutMs: number;
38
42
  snapshot: () => PersistedSnapshot;
39
43
  commit: (next: PersistedSnapshot) => void;
40
44
  emit: (event: PaymentEventInput) => void;
@@ -86,6 +90,25 @@ export function createFinalizer(deps: FinalizerDeps): Finalizer {
86
90
  });
87
91
  };
88
92
 
93
+ /**
94
+ * One store call under `attemptTimeoutMs`. A call that never settles used to
95
+ * hold this transaction's in-flight guard and, through `drain`, the sync that
96
+ * awaited it. Timing out counts as a failed attempt, so the retry schedule and
97
+ * the next-session drain both still apply.
98
+ */
99
+ const bounded = (call: Promise<void>, operation: 'finalize' | 'consume'): Promise<void> =>
100
+ withTimeout(
101
+ call,
102
+ deps.attemptTimeoutMs,
103
+ () =>
104
+ new PaymentError({
105
+ code: 'PAYMENT_TIMEOUT',
106
+ message: `The store did not answer the ${operation} call in time`,
107
+ operation,
108
+ retryable: true,
109
+ }),
110
+ );
111
+
89
112
  const clearTimer = (transactionId: string): void => {
90
113
  const timer = timers.get(transactionId);
91
114
  if (timer !== undefined) {
@@ -128,7 +151,7 @@ export function createFinalizer(deps: FinalizerDeps): Finalizer {
128
151
 
129
152
  let failed = false;
130
153
  try {
131
- await deps.provider.acknowledge(record.token);
154
+ await bounded(deps.provider.acknowledge(record.token), 'finalize');
132
155
  } catch {
133
156
  failed = true;
134
157
  } finally {
@@ -157,7 +180,72 @@ export function createFinalizer(deps: FinalizerDeps): Finalizer {
157
180
  }
158
181
 
159
182
  patch(transactionId, { finalizeState: 'failed', attempts });
160
- return scheduleRetry(transactionId, record.productKey, attempts);
183
+ return scheduleRetry(transactionId, record.productKey, attempts, 'acknowledge');
184
+ };
185
+
186
+ /**
187
+ * One consume call for a consumable the app has banked (FR-043).
188
+ *
189
+ * Refuses anything that is not a settled consumable. Consuming a subscription
190
+ * or a permanent unlock would tell the store the user used it up, and a
191
+ * pending purchase has nothing to consume yet.
192
+ */
193
+ const consumeAttempt = async (transactionId: string): Promise<FinalizeResult> => {
194
+ if (deps.disposed()) {
195
+ return { kind: 'failed', code: 'PAYMENT_NOT_CONFIGURED' };
196
+ }
197
+ const record = find(transactionId);
198
+ if (!record) {
199
+ return { kind: 'unknown_transaction' };
200
+ }
201
+ if (record.productType !== 'one_time_consumable') {
202
+ return { kind: 'failed', code: 'PAYMENT_NOT_CONSUMABLE' };
203
+ }
204
+ if (record.state === 'pending') {
205
+ return { kind: 'failed', code: 'PAYMENT_PENDING' };
206
+ }
207
+ if (record.state === 'consumed') {
208
+ // Consuming twice must grant the item once (FR-044).
209
+ return { kind: 'already_finalized' };
210
+ }
211
+ if (inFlight.has(transactionId)) {
212
+ // Concurrent consume calls collapse to one, for the same reason.
213
+ return { kind: 'already_finalized' };
214
+ }
215
+
216
+ inFlight.add(transactionId);
217
+ patch(transactionId, { finalizeState: 'in_flight' });
218
+
219
+ let failed = false;
220
+ try {
221
+ await bounded(deps.provider.consume(record.token), 'consume');
222
+ } catch {
223
+ failed = true;
224
+ } finally {
225
+ // Released before scheduling, for the same reason as in `attempt`.
226
+ inFlight.delete(transactionId);
227
+ }
228
+
229
+ const attempts = record.attempts + 1;
230
+
231
+ if (failed) {
232
+ patch(transactionId, { finalizeState: 'failed', attempts });
233
+ return scheduleRetry(transactionId, record.productKey, attempts, 'consume');
234
+ }
235
+
236
+ clearTimer(transactionId);
237
+ // `consumed` is what stops it being an entitlement source, so a
238
+ // consumable never becomes a permanent unlock.
239
+ patch(transactionId, {
240
+ state: 'consumed',
241
+ finalizeState: 'done',
242
+ attempts,
243
+ });
244
+ deps.emit({
245
+ type: 'transaction_consumed',
246
+ productKey: record.productKey,
247
+ });
248
+ return { kind: 'finalized' };
161
249
  };
162
250
 
163
251
  /**
@@ -172,6 +260,10 @@ export function createFinalizer(deps: FinalizerDeps): Finalizer {
172
260
  transactionId: string,
173
261
  productKey: ProductKey,
174
262
  attempts: number,
263
+ // The timer must repeat the call that failed. Retrying a failed consume
264
+ // through `attempt` was a no-op — `attempt` skips consumables — while the
265
+ // event still reported a retry as scheduled.
266
+ operation: 'acknowledge' | 'consume',
175
267
  ): FinalizeResult => {
176
268
  if (deps.disposed()) {
177
269
  return { kind: 'failed', code: 'PAYMENT_FINALIZE_FAILED' };
@@ -187,7 +279,8 @@ export function createFinalizer(deps: FinalizerDeps): Finalizer {
187
279
  transactionId,
188
280
  setTimeoutFn(() => {
189
281
  timers.delete(transactionId);
190
- attempt(transactionId).catch(() => undefined);
282
+ const retry = operation === 'consume' ? consumeAttempt : attempt;
283
+ retry(transactionId).catch(() => undefined);
191
284
  }, delayMs),
192
285
  );
193
286
 
@@ -229,53 +322,8 @@ export function createFinalizer(deps: FinalizerDeps): Finalizer {
229
322
  }
230
323
  },
231
324
 
232
- async consume(ref: TransactionRef): Promise<FinalizeResult> {
233
- const record = find(ref.transactionId);
234
- if (!record) {
235
- return { kind: 'unknown_transaction' };
236
- }
237
- if (record.state === 'consumed') {
238
- // Consuming twice must grant the item once (FR-044).
239
- return { kind: 'already_finalized' };
240
- }
241
- if (inFlight.has(ref.transactionId)) {
242
- // Concurrent consume calls collapse to one, for the same reason.
243
- return { kind: 'already_finalized' };
244
- }
245
-
246
- inFlight.add(ref.transactionId);
247
- patch(ref.transactionId, { finalizeState: 'in_flight' });
248
-
249
- let failed = false;
250
- try {
251
- await deps.provider.consume(record.token);
252
- } catch {
253
- failed = true;
254
- } finally {
255
- // Released before scheduling, for the same reason as in `attempt`.
256
- inFlight.delete(ref.transactionId);
257
- }
258
-
259
- const attempts = record.attempts + 1;
260
-
261
- if (failed) {
262
- patch(ref.transactionId, { finalizeState: 'failed', attempts });
263
- return scheduleRetry(ref.transactionId, record.productKey, attempts);
264
- }
265
-
266
- clearTimer(ref.transactionId);
267
- // `consumed` is what stops it being an entitlement source, so a
268
- // consumable never becomes a permanent unlock.
269
- patch(ref.transactionId, {
270
- state: 'consumed',
271
- finalizeState: 'done',
272
- attempts,
273
- });
274
- deps.emit({
275
- type: 'transaction_consumed',
276
- productKey: record.productKey,
277
- });
278
- return { kind: 'finalized' };
325
+ consume(ref: TransactionRef): Promise<FinalizeResult> {
326
+ return consumeAttempt(ref.transactionId);
279
327
  },
280
328
 
281
329
  unfinalized(): UnfinalizedSummary[] {
@@ -23,14 +23,18 @@ import type {
23
23
  PurchaseResult,
24
24
  TransactionRef,
25
25
  } from '../config/types';
26
- import type { ResolvedConfig } from '../config/validate';
26
+ import type { ResolvedConfig, TargetPlatform } from '../config/validate';
27
27
  import type {
28
28
  ProviderFailureReason,
29
29
  ProviderPort,
30
30
  ProviderPurchase,
31
+ ProviderPurchaseOutcome,
32
+ ProviderPurchaseRequest,
33
+ StoreProductId,
31
34
  } from '../adapter/port';
32
35
  import type { ExclusiveOutcome } from '../core/mutex';
33
36
  import { withTimeout } from '../core/mutex';
37
+ import { PURCHASE_FLOW_CEILING_MS } from '../core/timers';
34
38
  import { PaymentError } from '../diagnostics/errors';
35
39
  import type { PaymentEventInput } from '../diagnostics/events';
36
40
  import {
@@ -45,6 +49,7 @@ export interface PurchaseFlowDeps {
45
49
  config: PaymentConfig;
46
50
  resolved: ResolvedConfig;
47
51
  provider: ProviderPort;
52
+ platform: TargetPlatform;
48
53
  finalizer: Finalizer;
49
54
  snapshot: () => PersistedSnapshot;
50
55
  commit: (next: PersistedSnapshot) => void;
@@ -159,6 +164,29 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
159
164
  };
160
165
  };
161
166
 
167
+ /**
168
+ * Calls the app's hook under `timeouts.validateMs`.
169
+ *
170
+ * Unbounded, a hook that never answers held the purchase lock and, through
171
+ * revalidation, the sync flight for the rest of the session. A timeout rejects,
172
+ * so callers treat it exactly like a hook that throws: unavailable (FR-072b).
173
+ */
174
+ const askHook = (
175
+ hook: NonNullable<PaymentConfig['validateTransaction']>,
176
+ candidate: Parameters<NonNullable<PaymentConfig['validateTransaction']>>[0],
177
+ ) =>
178
+ withTimeout(
179
+ hook(candidate),
180
+ resolved.timeouts.validateMs,
181
+ () =>
182
+ new PaymentError({
183
+ code: 'PAYMENT_VALIDATION_UNAVAILABLE',
184
+ message: 'The validation hook did not answer in time',
185
+ operation: 'validate',
186
+ retryable: true,
187
+ }),
188
+ );
189
+
162
190
  /**
163
191
  * Runs the app's backend check, if configured.
164
192
  *
@@ -182,11 +210,13 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
182
210
 
183
211
  const entry = resolved.entries.get(productKey);
184
212
  try {
185
- const decision = await hook({
213
+ const decision = await askHook(hook, {
186
214
  productKey,
187
215
  productType: entry?.productType ?? 'one_time_permanent',
188
216
  verificationPayload: purchase.verificationPayload ?? '',
189
217
  correlationId,
218
+ transactionId: purchase.transactionId,
219
+ platform: deps.platform,
190
220
  });
191
221
  deps.emit({
192
222
  type: 'validation_decided',
@@ -206,6 +236,32 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
206
236
  }
207
237
  };
208
238
 
239
+ /**
240
+ * Settlements in progress, by transaction.
241
+ *
242
+ * The Play binding delivers a launched purchase twice: to the launch and, at
243
+ * the same moment, to the session-long update listener. Both paths reach
244
+ * `settle`, and without this the hook ran twice and the purchase was settled
245
+ * twice. The second caller joins the first and gets the same result.
246
+ */
247
+ const inFlightSettle = new Map<string, Promise<PurchaseResult>>();
248
+
249
+ const settleOnce = (
250
+ purchase: ProviderPurchase,
251
+ productKey: ProductKey,
252
+ correlationId: string,
253
+ ): Promise<PurchaseResult> => {
254
+ const running = inFlightSettle.get(purchase.transactionId);
255
+ if (running) {
256
+ return running;
257
+ }
258
+ const flight = settle(purchase, productKey, correlationId).finally(() => {
259
+ inFlightSettle.delete(purchase.transactionId);
260
+ });
261
+ inFlightSettle.set(purchase.transactionId, flight);
262
+ return flight;
263
+ };
264
+
209
265
  /** Persists a completed purchase, grants it, then finalizes. */
210
266
  const settle = async (
211
267
  purchase: ProviderPurchase,
@@ -261,8 +317,16 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
261
317
  purchase: ProviderPurchase,
262
318
  productKey: ProductKey,
263
319
  ): PurchaseResult => {
320
+ // The same pending transaction arrives from the launch and from the update
321
+ // listener (see `inFlightSettle`). The signal is about a purchase becoming
322
+ // pending, so it fires once per transaction, not once per delivery.
323
+ const alreadyPending = deps
324
+ .snapshot()
325
+ .purchases.some(p => p.transactionId === purchase.transactionId && p.state === 'pending');
264
326
  upsert(recordFor(purchase, productKey));
265
- deps.emit({ type: 'purchase_pending', productKey });
327
+ if (!alreadyPending) {
328
+ deps.emit({ type: 'purchase_pending', productKey });
329
+ }
266
330
  return {
267
331
  kind: 'pending',
268
332
  productKey,
@@ -291,6 +355,58 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
291
355
  return { kind: 'store_error', productKey, code: 'PAYMENT_STORE_ERROR' };
292
356
  };
293
357
 
358
+ /** What the store is asked for: the product and, for a plan change, what it replaces. */
359
+ const launchRequest = (
360
+ productKey: ProductKey,
361
+ storeProductId: StoreProductId,
362
+ options?: InternalPurchaseOptions,
363
+ ): ProviderPurchaseRequest => {
364
+ const replaceKey = options?.replaceProductKey;
365
+ return {
366
+ storeProductId,
367
+ productType: resolved.entries.get(productKey)?.productType ?? 'one_time_permanent',
368
+ offerKey: options?.offerKey,
369
+ obfuscatedAccountId: options?.obfuscatedAccountId,
370
+ // Present only for a plan change. The store needs the token of the
371
+ // subscription being replaced so it migrates the existing purchase instead
372
+ // of creating a second one (FR-065), and its product id to apply the
373
+ // replacement mode.
374
+ replacePurchaseToken: replaceKey
375
+ ? deps
376
+ .snapshot()
377
+ .purchases.find(p => p.productKey === replaceKey && p.state === 'purchased')
378
+ ?.token
379
+ : undefined,
380
+ replaceStoreProductId: replaceKey
381
+ ? storeIdForProductKey(resolved, replaceKey)
382
+ : undefined,
383
+ prorationMode: options?.prorationMode,
384
+ };
385
+ };
386
+
387
+ /**
388
+ * The launch timeout covers opening the store surface only; time the user
389
+ * spends inside it is the store's, not ours (FR-035). A provider that reports
390
+ * nothing until the flow ends gives no "opened" moment to time, so it gets only
391
+ * `PURCHASE_FLOW_CEILING_MS`: far beyond any real purchase, there so a binding
392
+ * that never answers cannot hold the purchase lock for the whole session.
393
+ */
394
+ const launch = (request: ProviderPurchaseRequest): Promise<ProviderPurchaseOutcome> => {
395
+ const launched = deps.provider.launchPurchase(request);
396
+ const includesStoreTime = deps.provider.capabilities().purchaseOutcomeIncludesStoreTime;
397
+ return withTimeout(
398
+ launched,
399
+ includesStoreTime ? PURCHASE_FLOW_CEILING_MS : resolved.timeouts.purchaseLaunchMs,
400
+ () =>
401
+ new PaymentError({
402
+ code: 'PAYMENT_TIMEOUT',
403
+ message: 'Opening the purchase surface timed out',
404
+ operation: 'purchase',
405
+ retryable: true,
406
+ }),
407
+ );
408
+ };
409
+
294
410
  return {
295
411
  async purchase(
296
412
  productKey: ProductKey,
@@ -325,7 +441,6 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
325
441
  };
326
442
  }
327
443
 
328
- const entry = resolved.entries.get(productKey);
329
444
  const correlationId = deps.nextCorrelationId();
330
445
 
331
446
  // Exclusive, not single-flight: a second purchase is refused rather than
@@ -335,37 +450,7 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
335
450
 
336
451
  let outcome;
337
452
  try {
338
- outcome = await 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
- );
453
+ outcome = await launch(launchRequest(productKey, storeProductId, options));
369
454
  } catch {
370
455
  deps.emit({
371
456
  type: 'purchase_failed',
@@ -421,7 +506,7 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
421
506
  }
422
507
 
423
508
  deps.emit({ type: 'purchase_succeeded', productKey, correlationId });
424
- return settle(outcome.purchase, productKey, correlationId);
509
+ return settleOnce(outcome.purchase, productKey, correlationId);
425
510
  });
426
511
 
427
512
  if (!attempt.acquired) {
@@ -466,7 +551,7 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
466
551
  return;
467
552
  }
468
553
 
469
- await settle(purchase, productKey, deps.nextCorrelationId());
554
+ await settleOnce(purchase, productKey, deps.nextCorrelationId());
470
555
  },
471
556
 
472
557
  async revalidate(): Promise<void> {
@@ -483,14 +568,17 @@ export function createPurchaseFlow(deps: PurchaseFlowDeps): PurchaseFlow {
483
568
  const entry = resolved.entries.get(record.productKey);
484
569
  let approved: boolean;
485
570
  try {
486
- const decision = await hook({
571
+ const decision = await askHook(hook, {
487
572
  productKey: record.productKey,
488
573
  productType: entry?.productType ?? 'one_time_permanent',
489
574
  // Re-validation deliberately carries no payload: the original
490
575
  // verification material is not persisted (FR-056), so the backend
491
- // re-checks by transaction against its own record of the purchase.
576
+ // re-checks by transaction against its own record of the purchase —
577
+ // which is why the transaction id has to travel with it.
492
578
  verificationPayload: '',
493
579
  correlationId: deps.nextCorrelationId(),
580
+ transactionId: record.transactionId,
581
+ platform: deps.platform,
494
582
  });
495
583
  approved = decision.approved;
496
584
  } catch {
@@ -32,6 +32,13 @@ export interface FakeStoreOptions {
32
32
  capabilities?: Partial<ProviderCapabilities>;
33
33
  products?: ProviderProduct[];
34
34
  owned?: ProviderPurchase[];
35
+ /**
36
+ * `'android-16.7'` mirrors the Play binding the real adapter wraps: a launched
37
+ * purchase is also delivered through `observePurchases`, so the engine sees it
38
+ * twice, and capabilities match Android's. The default delivers it once, which
39
+ * is simpler but hides any double-settle on the device path.
40
+ */
41
+ mode?: 'default' | 'android-16.7';
35
42
  }
36
43
 
37
44
  export interface FakeStore extends ProviderPort {
@@ -98,6 +105,9 @@ const DEFAULT_CAPABILITIES: ProviderCapabilities = {
98
105
  // lets a test complete a purchase while still blocking the real adapter
99
106
  // outside production (FR-014).
100
107
  createsRealTransactions: false,
108
+ // Its launch returns at once, so the engine's launch timeout applies and stays
109
+ // testable (FR-035).
110
+ purchaseOutcomeIncludesStoreTime: false,
101
111
  };
102
112
 
103
113
  let sequence = 0;
@@ -124,8 +134,10 @@ export function createFakeStore(options: FakeStoreOptions = {}): FakeStore {
124
134
  let connectOutcome: ProviderConnectOutcome = options.connectOutcome ?? {
125
135
  ok: true,
126
136
  };
137
+ const androidBinding = options.mode === 'android-16.7';
127
138
  let capabilities: ProviderCapabilities = {
128
139
  ...DEFAULT_CAPABILITIES,
140
+ ...(androidBinding ? { supportsPriceChangeConfirmation: false } : {}),
129
141
  ...options.capabilities,
130
142
  };
131
143
  let products = [...(options.products ?? [])];
@@ -228,6 +240,13 @@ export function createFakeStore(options: FakeStoreOptions = {}): FakeStore {
228
240
  ...script.overrides,
229
241
  });
230
242
  owned = [...owned.filter(p => p.transactionId !== purchase.transactionId), purchase];
243
+ if (androidBinding) {
244
+ // The binding's update listener fires for every observer, the session-long
245
+ // one included, before the launch settles.
246
+ for (const listener of [...purchaseListeners]) {
247
+ listener(purchase);
248
+ }
249
+ }
231
250
  return { ok: true, purchase };
232
251
  },
233
252