@chipmobilesdk/rn-payment 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,267 +1,272 @@
1
- /**
2
- * Timeout, retry, and cache policy.
3
- *
4
- * Defaults and bounds live here rather than at each call site so there is one
5
- * place to read what the package will wait for, and so consumers can read the
6
- * values they are bound by instead of discovering them from a timeout in
7
- * production.
8
- *
9
- * Out-of-band values are a configuration error rather than being silently
10
- * clamped: quietly turning a 1-second timeout into 3 seconds would tell the app
11
- * it configured something it did not.
12
- */
13
-
14
- import { PaymentConfigError } from '../diagnostics/errors';
15
- import type {
16
- BackoffConfig,
17
- RetryConfig,
18
- TimeoutConfig,
19
- } from '../config/types';
20
-
21
- /** Local IPC to Play Services. Slower than this means unavailable, not busy. */
22
- export const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
23
- /** A network round trip; a paywall that hangs longer should show its error state. */
24
- export const DEFAULT_PRODUCT_QUERY_TIMEOUT_MS = 15_000;
25
- /** Runs at startup, so it must not hold the first render hostage. */
26
- export const DEFAULT_SYNC_TIMEOUT_MS = 20_000;
27
- /**
28
- * Covers opening the store surface only.
29
- *
30
- * Time the user spends *inside* the store's own UI is deliberately excluded
31
- * (FR-035): a user reading the terms, switching to their banking app for an OTP,
32
- * or simply hesitating must never have their purchase cancelled by our clock.
33
- */
34
- export const DEFAULT_PURCHASE_LAUNCH_TIMEOUT_MS = 30_000;
35
- /** Per finalize attempt, not per transaction — the queue owns overall persistence. */
36
- export const DEFAULT_FINALIZE_ATTEMPT_TIMEOUT_MS = 15_000;
37
- /** The app's backend hook. A user is waiting on a purchase result behind it. */
38
- export const DEFAULT_VALIDATE_TIMEOUT_MS = 15_000;
39
- /**
40
- * The ceiling on a purchase flow whose outcome includes the user's own time in
41
- * the store UI, so is otherwise untimed (FR-035). Not a timeout on the user: it
42
- * exists only so a binding that never answers cannot hold the purchase lock for
43
- * the rest of the session. A purchase that completes later still arrives through
44
- * the store update channel and is granted then.
45
- */
46
- export const PURCHASE_FLOW_CEILING_MS = 10 * 60_000;
47
-
48
- export const CONNECT_TIMEOUT_BOUNDS = { min: 3_000, max: 60_000 } as const;
49
- export const PRODUCT_QUERY_TIMEOUT_BOUNDS = { min: 3_000, max: 60_000 } as const;
50
- export const SYNC_TIMEOUT_BOUNDS = { min: 5_000, max: 120_000 } as const;
51
- export const PURCHASE_LAUNCH_TIMEOUT_BOUNDS = {
52
- min: 5_000,
53
- max: 120_000,
54
- } as const;
55
- export const FINALIZE_ATTEMPT_TIMEOUT_BOUNDS = {
56
- min: 3_000,
57
- max: 60_000,
58
- } as const;
59
- export const VALIDATE_TIMEOUT_BOUNDS = { min: 3_000, max: 120_000 } as const;
60
-
61
- /**
62
- * Prices and offer eligibility are shown at the moment of purchase, so a short
63
- * TTL with on-demand refresh beats a long one (FR-029).
64
- */
65
- export const DEFAULT_PRODUCT_CACHE_TTL_MS = 900_000;
66
- export const PRODUCT_CACHE_TTL_BOUNDS = { min: 60_000, max: 86_400_000 } as const;
67
-
68
- /**
69
- * How long a successful sync keeps entitlements trustworthy offline: **7 days**.
70
- *
71
- * Fixed by the specification (FR-055) after weighing both failure directions.
72
- * The realistic offline cases — travel, poor coverage, a device left unused —
73
- * resolve within days. Seven days covers a holiday without connectivity while
74
- * bounding how long a refunded entitlement can outlive its refund to one week.
75
- *
76
- * Past this the entitlement becomes `unknown`, never `not_entitled` (FR-055a):
77
- * the package stops asserting rather than telling a paying customer they did not
78
- * pay.
79
- */
80
- export const DEFAULT_STALE_AFTER_MS = 604_800_000;
81
- export const STALE_AFTER_BOUNDS = {
82
- min: 3_600_000,
83
- max: 7_776_000_000,
84
- } as const;
85
-
86
- /** Keeps rapid app switching from becoming a request storm (PERF-002). */
87
- export const DEFAULT_FOREGROUND_SYNC_MIN_INTERVAL_MS = 60_000;
88
- export const FOREGROUND_SYNC_MIN_INTERVAL_BOUNDS = {
89
- min: 0,
90
- max: 3_600_000,
91
- } as const;
92
-
93
- /** Bounded so a dropped billing connection cannot become a retry storm (FR-020). */
94
- export const DEFAULT_RECONNECT_BACKOFF: Required<BackoffConfig> = {
95
- initialDelayMs: 1_000,
96
- maxDelayMs: 30_000,
97
- factor: 2,
98
- maxAttempts: 5,
99
- jitter: true,
100
- };
101
-
102
- /**
103
- * Finalize retry.
104
- *
105
- * Bounded **per session** but never permanently abandoned: Google auto-refunds
106
- * an unacknowledged purchase after three days, so exhausting this session's
107
- * attempts must leave the transaction queued for the next launch, not dropped
108
- * (FR-045).
109
- */
110
- export const DEFAULT_FINALIZE_BACKOFF: Required<BackoffConfig> = {
111
- initialDelayMs: 2_000,
112
- maxDelayMs: 60_000,
113
- factor: 2,
114
- maxAttempts: 5,
115
- jitter: true,
116
- };
117
-
118
- export const BACKOFF_MAX_DELAY_BOUNDS = { min: 5_000, max: 600_000 } as const;
119
- export const BACKOFF_ATTEMPT_BOUNDS = { min: 1, max: 10 } as const;
120
-
121
- interface Bounds {
122
- readonly min: number;
123
- readonly max: number;
124
- }
125
-
126
- function requireInBounds(
127
- label: string,
128
- value: number | undefined,
129
- fallback: number,
130
- bounds: Bounds,
131
- ): number {
132
- if (value === undefined) {
133
- return fallback;
134
- }
135
- if (!Number.isFinite(value) || value < bounds.min || value > bounds.max) {
136
- throw new PaymentConfigError({
137
- code: 'PAYMENT_INVALID_CATALOG',
138
- message: `${label} must be between ${bounds.min} and ${bounds.max}ms, received ${value}`,
139
- operation: 'configure',
140
- offenders: [label],
141
- });
142
- }
143
- return value;
144
- }
145
-
146
- export interface ResolvedTimeouts {
147
- connectMs: number;
148
- productQueryMs: number;
149
- syncMs: number;
150
- purchaseLaunchMs: number;
151
- finalizeAttemptMs: number;
152
- validateMs: number;
153
- }
154
-
155
- export function resolveTimeouts(config?: TimeoutConfig): ResolvedTimeouts {
156
- return {
157
- connectMs: requireInBounds(
158
- 'timeouts.connectMs',
159
- config?.connectMs,
160
- DEFAULT_CONNECT_TIMEOUT_MS,
161
- CONNECT_TIMEOUT_BOUNDS,
162
- ),
163
- productQueryMs: requireInBounds(
164
- 'timeouts.productQueryMs',
165
- config?.productQueryMs,
166
- DEFAULT_PRODUCT_QUERY_TIMEOUT_MS,
167
- PRODUCT_QUERY_TIMEOUT_BOUNDS,
168
- ),
169
- syncMs: requireInBounds(
170
- 'timeouts.syncMs',
171
- config?.syncMs,
172
- DEFAULT_SYNC_TIMEOUT_MS,
173
- SYNC_TIMEOUT_BOUNDS,
174
- ),
175
- purchaseLaunchMs: requireInBounds(
176
- 'timeouts.purchaseLaunchMs',
177
- config?.purchaseLaunchMs,
178
- DEFAULT_PURCHASE_LAUNCH_TIMEOUT_MS,
179
- PURCHASE_LAUNCH_TIMEOUT_BOUNDS,
180
- ),
181
- finalizeAttemptMs: requireInBounds(
182
- 'timeouts.finalizeAttemptMs',
183
- config?.finalizeAttemptMs,
184
- DEFAULT_FINALIZE_ATTEMPT_TIMEOUT_MS,
185
- FINALIZE_ATTEMPT_TIMEOUT_BOUNDS,
186
- ),
187
- validateMs: requireInBounds(
188
- 'timeouts.validateMs',
189
- config?.validateMs,
190
- DEFAULT_VALIDATE_TIMEOUT_MS,
191
- VALIDATE_TIMEOUT_BOUNDS,
192
- ),
193
- };
194
- }
195
-
196
- function resolveBackoff(
197
- label: string,
198
- config: BackoffConfig | undefined,
199
- fallback: Required<BackoffConfig>,
200
- ): Required<BackoffConfig> {
201
- const maxDelayMs = requireInBounds(
202
- `${label}.maxDelayMs`,
203
- config?.maxDelayMs,
204
- fallback.maxDelayMs,
205
- BACKOFF_MAX_DELAY_BOUNDS,
206
- );
207
- const maxAttempts = requireInBounds(
208
- `${label}.maxAttempts`,
209
- config?.maxAttempts,
210
- fallback.maxAttempts,
211
- BACKOFF_ATTEMPT_BOUNDS,
212
- );
213
- return {
214
- initialDelayMs: config?.initialDelayMs ?? fallback.initialDelayMs,
215
- maxDelayMs,
216
- factor: config?.factor ?? fallback.factor,
217
- maxAttempts,
218
- jitter: config?.jitter ?? fallback.jitter,
219
- };
220
- }
221
-
222
- export interface ResolvedRetry {
223
- reconnect: Required<BackoffConfig>;
224
- finalize: Required<BackoffConfig>;
225
- }
226
-
227
- export function resolveRetry(config?: RetryConfig): ResolvedRetry {
228
- return {
229
- reconnect: resolveBackoff(
230
- 'retry.reconnect',
231
- config?.reconnect,
232
- DEFAULT_RECONNECT_BACKOFF,
233
- ),
234
- finalize: resolveBackoff(
235
- 'retry.finalize',
236
- config?.finalize,
237
- DEFAULT_FINALIZE_BACKOFF,
238
- ),
239
- };
240
- }
241
-
242
- export function resolveStaleAfterMs(value?: number): number {
243
- return requireInBounds(
244
- 'staleAfterMs',
245
- value,
246
- DEFAULT_STALE_AFTER_MS,
247
- STALE_AFTER_BOUNDS,
248
- );
249
- }
250
-
251
- export function resolveProductCacheTtlMs(value?: number): number {
252
- return requireInBounds(
253
- 'productCacheTtlMs',
254
- value,
255
- DEFAULT_PRODUCT_CACHE_TTL_MS,
256
- PRODUCT_CACHE_TTL_BOUNDS,
257
- );
258
- }
259
-
260
- export function resolveForegroundSyncMinIntervalMs(value?: number): number {
261
- return requireInBounds(
262
- 'foregroundSyncMinIntervalMs',
263
- value,
264
- DEFAULT_FOREGROUND_SYNC_MIN_INTERVAL_MS,
265
- FOREGROUND_SYNC_MIN_INTERVAL_BOUNDS,
266
- );
267
- }
1
+ /**
2
+ * Timeout, retry, and cache policy.
3
+ *
4
+ * Defaults and bounds live here rather than at each call site so there is one
5
+ * place to read what the package will wait for, and so consumers can read the
6
+ * values they are bound by instead of discovering them from a timeout in
7
+ * production.
8
+ *
9
+ * Out-of-band values are a configuration error rather than being silently
10
+ * clamped: quietly turning a 1-second timeout into 3 seconds would tell the app
11
+ * it configured something it did not.
12
+ */
13
+
14
+ import { checkRange, type Range } from '@chipmobilesdk/rn-core';
15
+
16
+ import { PaymentConfigError } from '../diagnostics/errors';
17
+ import type { BackoffConfig, RetryConfig, TimeoutConfig } from '../config/types';
18
+
19
+ /** Local IPC to Play Services. Slower than this means unavailable, not busy. */
20
+ export const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
21
+ /** A network round trip; a paywall that hangs longer should show its error state. */
22
+ export const DEFAULT_PRODUCT_QUERY_TIMEOUT_MS = 15_000;
23
+ /** Runs at startup, so it must not hold the first render hostage. */
24
+ export const DEFAULT_SYNC_TIMEOUT_MS = 20_000;
25
+ /**
26
+ * Covers opening the store surface only.
27
+ *
28
+ * Time the user spends *inside* the store's own UI is deliberately excluded
29
+ * (FR-035): a user reading the terms, switching to their banking app for an OTP,
30
+ * or simply hesitating must never have their purchase cancelled by our clock.
31
+ */
32
+ export const DEFAULT_PURCHASE_LAUNCH_TIMEOUT_MS = 30_000;
33
+ /** Per finalize attempt, not per transaction — the queue owns overall persistence. */
34
+ export const DEFAULT_FINALIZE_ATTEMPT_TIMEOUT_MS = 15_000;
35
+ /** The app's backend hook. A user is waiting on a purchase result behind it. */
36
+ export const DEFAULT_VALIDATE_TIMEOUT_MS = 15_000;
37
+ /**
38
+ * The ceiling on a purchase flow whose outcome includes the user's own time in
39
+ * the store UI, so is otherwise untimed (FR-035). Not a timeout on the user: it
40
+ * exists only so a binding that never answers cannot hold the purchase lock for
41
+ * the rest of the session. A purchase that completes later still arrives through
42
+ * the store update channel and is granted then.
43
+ */
44
+ export const PURCHASE_FLOW_CEILING_MS = 10 * 60_000;
45
+
46
+ export const CONNECT_TIMEOUT_BOUNDS = { min: 3_000, max: 60_000 } as const;
47
+ export const PRODUCT_QUERY_TIMEOUT_BOUNDS = { min: 3_000, max: 60_000 } as const;
48
+ export const SYNC_TIMEOUT_BOUNDS = { min: 5_000, max: 120_000 } as const;
49
+ export const PURCHASE_LAUNCH_TIMEOUT_BOUNDS = {
50
+ min: 5_000,
51
+ max: 120_000,
52
+ } as const;
53
+ export const FINALIZE_ATTEMPT_TIMEOUT_BOUNDS = {
54
+ min: 3_000,
55
+ max: 60_000,
56
+ } as const;
57
+ export const VALIDATE_TIMEOUT_BOUNDS = { min: 3_000, max: 120_000 } as const;
58
+
59
+ /**
60
+ * Prices and offer eligibility are shown at the moment of purchase, so a short
61
+ * TTL with on-demand refresh beats a long one (FR-029).
62
+ */
63
+ export const DEFAULT_PRODUCT_CACHE_TTL_MS = 900_000;
64
+ export const PRODUCT_CACHE_TTL_BOUNDS = { min: 60_000, max: 86_400_000 } as const;
65
+
66
+ /**
67
+ * How long a successful sync keeps entitlements trustworthy offline: **7 days**.
68
+ *
69
+ * Fixed by the specification (FR-055) after weighing both failure directions.
70
+ * The realistic offline cases — travel, poor coverage, a device left unused —
71
+ * resolve within days. Seven days covers a holiday without connectivity while
72
+ * bounding how long a refunded entitlement can outlive its refund to one week.
73
+ *
74
+ * Past this the entitlement becomes `unknown`, never `not_entitled` (FR-055a):
75
+ * the package stops asserting rather than telling a paying customer they did not
76
+ * pay.
77
+ */
78
+ export const DEFAULT_STALE_AFTER_MS = 604_800_000;
79
+ export const STALE_AFTER_BOUNDS = {
80
+ min: 3_600_000,
81
+ max: 7_776_000_000,
82
+ } as const;
83
+
84
+ /** Keeps rapid app switching from becoming a request storm (PERF-002). */
85
+ export const DEFAULT_FOREGROUND_SYNC_MIN_INTERVAL_MS = 60_000;
86
+ export const FOREGROUND_SYNC_MIN_INTERVAL_BOUNDS = {
87
+ min: 0,
88
+ max: 3_600_000,
89
+ } as const;
90
+
91
+ /** Bounded so a dropped billing connection cannot become a retry storm (FR-020). */
92
+ export const DEFAULT_RECONNECT_BACKOFF: Required<BackoffConfig> = {
93
+ initialDelayMs: 1_000,
94
+ maxDelayMs: 30_000,
95
+ factor: 2,
96
+ maxAttempts: 5,
97
+ jitter: true,
98
+ };
99
+
100
+ /**
101
+ * Finalize retry.
102
+ *
103
+ * Bounded **per session** but never permanently abandoned: Google auto-refunds
104
+ * an unacknowledged purchase after three days, so exhausting this session's
105
+ * attempts must leave the transaction queued for the next launch, not dropped
106
+ * (FR-045).
107
+ */
108
+ export const DEFAULT_FINALIZE_BACKOFF: Required<BackoffConfig> = {
109
+ initialDelayMs: 2_000,
110
+ maxDelayMs: 60_000,
111
+ factor: 2,
112
+ maxAttempts: 5,
113
+ jitter: true,
114
+ };
115
+
116
+ export const BACKOFF_MAX_DELAY_BOUNDS = { min: 5_000, max: 600_000 } as const;
117
+ export const BACKOFF_ATTEMPT_BOUNDS = { min: 1, max: 10 } as const;
118
+ /** A first retry sooner than this is a storm; later than this, the user has given up. */
119
+ export const BACKOFF_INITIAL_DELAY_BOUNDS = { min: 100, max: 60_000 } as const;
120
+ /** 1 is a constant delay; 0 or below would retry at once, forever (CORE-05). */
121
+ export const BACKOFF_FACTOR_BOUNDS = { min: 1, max: 10 } as const;
122
+
123
+ function invalidConfig(label: string) {
124
+ return (message: string) =>
125
+ new PaymentConfigError({
126
+ code: 'PAYMENT_INVALID_CONFIG',
127
+ message,
128
+ operation: 'configure',
129
+ offenders: [label],
130
+ });
131
+ }
132
+
133
+ function requireInBounds(
134
+ label: string,
135
+ value: number | undefined,
136
+ fallback: number,
137
+ bounds: Range,
138
+ unit = 'ms',
139
+ ): number {
140
+ return value === undefined
141
+ ? fallback
142
+ : checkRange(label, value, bounds, invalidConfig(label), unit);
143
+ }
144
+
145
+ export interface ResolvedTimeouts {
146
+ connectMs: number;
147
+ productQueryMs: number;
148
+ syncMs: number;
149
+ purchaseLaunchMs: number;
150
+ finalizeAttemptMs: number;
151
+ validateMs: number;
152
+ }
153
+
154
+ export function resolveTimeouts(config?: TimeoutConfig): ResolvedTimeouts {
155
+ return {
156
+ connectMs: requireInBounds(
157
+ 'timeouts.connectMs',
158
+ config?.connectMs,
159
+ DEFAULT_CONNECT_TIMEOUT_MS,
160
+ CONNECT_TIMEOUT_BOUNDS,
161
+ ),
162
+ productQueryMs: requireInBounds(
163
+ 'timeouts.productQueryMs',
164
+ config?.productQueryMs,
165
+ DEFAULT_PRODUCT_QUERY_TIMEOUT_MS,
166
+ PRODUCT_QUERY_TIMEOUT_BOUNDS,
167
+ ),
168
+ syncMs: requireInBounds(
169
+ 'timeouts.syncMs',
170
+ config?.syncMs,
171
+ DEFAULT_SYNC_TIMEOUT_MS,
172
+ SYNC_TIMEOUT_BOUNDS,
173
+ ),
174
+ purchaseLaunchMs: requireInBounds(
175
+ 'timeouts.purchaseLaunchMs',
176
+ config?.purchaseLaunchMs,
177
+ DEFAULT_PURCHASE_LAUNCH_TIMEOUT_MS,
178
+ PURCHASE_LAUNCH_TIMEOUT_BOUNDS,
179
+ ),
180
+ finalizeAttemptMs: requireInBounds(
181
+ 'timeouts.finalizeAttemptMs',
182
+ config?.finalizeAttemptMs,
183
+ DEFAULT_FINALIZE_ATTEMPT_TIMEOUT_MS,
184
+ FINALIZE_ATTEMPT_TIMEOUT_BOUNDS,
185
+ ),
186
+ validateMs: requireInBounds(
187
+ 'timeouts.validateMs',
188
+ config?.validateMs,
189
+ DEFAULT_VALIDATE_TIMEOUT_MS,
190
+ VALIDATE_TIMEOUT_BOUNDS,
191
+ ),
192
+ };
193
+ }
194
+
195
+ function resolveBackoff(
196
+ label: string,
197
+ config: BackoffConfig | undefined,
198
+ fallback: Required<BackoffConfig>,
199
+ ): Required<BackoffConfig> {
200
+ const maxDelayMs = requireInBounds(
201
+ `${label}.maxDelayMs`,
202
+ config?.maxDelayMs,
203
+ fallback.maxDelayMs,
204
+ BACKOFF_MAX_DELAY_BOUNDS,
205
+ );
206
+ const maxAttempts = requireInBounds(
207
+ `${label}.maxAttempts`,
208
+ config?.maxAttempts,
209
+ fallback.maxAttempts,
210
+ BACKOFF_ATTEMPT_BOUNDS,
211
+ '',
212
+ );
213
+ const initialDelayMs = requireInBounds(
214
+ `${label}.initialDelayMs`,
215
+ config?.initialDelayMs,
216
+ fallback.initialDelayMs,
217
+ BACKOFF_INITIAL_DELAY_BOUNDS,
218
+ );
219
+ const factor = requireInBounds(
220
+ `${label}.factor`,
221
+ config?.factor,
222
+ fallback.factor,
223
+ BACKOFF_FACTOR_BOUNDS,
224
+ '',
225
+ );
226
+ if (initialDelayMs > maxDelayMs) {
227
+ throw invalidConfig(`${label}.initialDelayMs`)(
228
+ `${label}.initialDelayMs (${initialDelayMs}ms) exceeds ${label}.maxDelayMs (${maxDelayMs}ms)`,
229
+ );
230
+ }
231
+ return {
232
+ initialDelayMs,
233
+ maxDelayMs,
234
+ factor,
235
+ maxAttempts,
236
+ jitter: config?.jitter ?? fallback.jitter,
237
+ };
238
+ }
239
+
240
+ export interface ResolvedRetry {
241
+ reconnect: Required<BackoffConfig>;
242
+ finalize: Required<BackoffConfig>;
243
+ }
244
+
245
+ export function resolveRetry(config?: RetryConfig): ResolvedRetry {
246
+ return {
247
+ reconnect: resolveBackoff('retry.reconnect', config?.reconnect, DEFAULT_RECONNECT_BACKOFF),
248
+ finalize: resolveBackoff('retry.finalize', config?.finalize, DEFAULT_FINALIZE_BACKOFF),
249
+ };
250
+ }
251
+
252
+ export function resolveStaleAfterMs(value?: number): number {
253
+ return requireInBounds('staleAfterMs', value, DEFAULT_STALE_AFTER_MS, STALE_AFTER_BOUNDS);
254
+ }
255
+
256
+ export function resolveProductCacheTtlMs(value?: number): number {
257
+ return requireInBounds(
258
+ 'productCacheTtlMs',
259
+ value,
260
+ DEFAULT_PRODUCT_CACHE_TTL_MS,
261
+ PRODUCT_CACHE_TTL_BOUNDS,
262
+ );
263
+ }
264
+
265
+ export function resolveForegroundSyncMinIntervalMs(value?: number): number {
266
+ return requireInBounds(
267
+ 'foregroundSyncMinIntervalMs',
268
+ value,
269
+ DEFAULT_FOREGROUND_SYNC_MIN_INTERVAL_MS,
270
+ FOREGROUND_SYNC_MIN_INTERVAL_BOUNDS,
271
+ );
272
+ }