@chipmobilesdk/rn-payment 0.0.0-stage → 0.1.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 +112 -0
- package/README.md +760 -2
- package/package.json +54 -6
- package/skills/sdk-rn-payment/SKILL.md +137 -0
- package/src/adapter/createStoreProvider.ts +117 -0
- package/src/adapter/iap-adapter.ts +516 -0
- package/src/adapter/port.ts +186 -0
- package/src/config/types.ts +255 -0
- package/src/config/validate.ts +232 -0
- package/src/core/mutex.ts +128 -0
- package/src/core/retry.ts +133 -0
- package/src/core/timers.ts +249 -0
- package/src/diagnostics/errors.ts +110 -0
- package/src/diagnostics/events.ts +257 -0
- package/src/diagnostics/messageKeys.ts +95 -0
- package/src/engine/engine.ts +854 -0
- package/src/engine/finalize.ts +298 -0
- package/src/engine/products.ts +259 -0
- package/src/engine/purchase.ts +495 -0
- package/src/engine/subscriptions.ts +157 -0
- package/src/entitlements/aggregate.ts +225 -0
- package/src/entitlements/mapping.ts +67 -0
- package/src/entitlements/policy.ts +129 -0
- package/src/index.ts +168 -0
- package/src/persistence/mmkv-store.ts +150 -0
- package/src/persistence/snapshot.ts +247 -0
- package/src/react/PaymentProvider.tsx +79 -0
- package/src/react/hooks.ts +238 -0
- package/src/testing/fakeStore.ts +341 -0
- package/src/testing/index.ts +10 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded backoff.
|
|
3
|
+
*
|
|
4
|
+
* "Bounded" is the whole point (FR-020, FR-045): an unbounded reconnect loop
|
|
5
|
+
* against a billing service that is down drains battery and hammers a system
|
|
6
|
+
* service, and an unbounded finalize loop hides a real failure behind noise.
|
|
7
|
+
*
|
|
8
|
+
* Delay computation is pure and separated from waiting so tests can assert the
|
|
9
|
+
* schedule without burning real time.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { BackoffConfig } from '../config/types';
|
|
13
|
+
|
|
14
|
+
/** Opaque timer handle: a Node Timeout and a browser numeric id are both valid. */
|
|
15
|
+
type TimerHandle = ReturnType<typeof setTimeout> | number;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Delay before `attempt` (1-based), capped and optionally jittered.
|
|
19
|
+
*
|
|
20
|
+
* Jitter is full jitter over [0, capped]: several devices that lost the billing
|
|
21
|
+
* connection at the same moment should not retry in lockstep.
|
|
22
|
+
*/
|
|
23
|
+
export function backoffDelayMs(
|
|
24
|
+
attempt: number,
|
|
25
|
+
config: Required<BackoffConfig>,
|
|
26
|
+
random: () => number = Math.random,
|
|
27
|
+
): number {
|
|
28
|
+
const raw = config.initialDelayMs * Math.pow(config.factor, attempt - 1);
|
|
29
|
+
const capped = Math.min(raw, config.maxDelayMs);
|
|
30
|
+
return config.jitter ? Math.floor(capped * random()) : capped;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The full schedule, for asserting a retry policy without waiting for it. */
|
|
34
|
+
export function backoffSchedule(config: Required<BackoffConfig>): number[] {
|
|
35
|
+
const delays: number[] = [];
|
|
36
|
+
for (let attempt = 1; attempt <= config.maxAttempts; attempt += 1) {
|
|
37
|
+
delays.push(backoffDelayMs(attempt, config, () => 1));
|
|
38
|
+
}
|
|
39
|
+
return delays;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RetryHandle {
|
|
43
|
+
/** Cancels any pending attempt. Idempotent, so cleanup can call it freely (FR-082). */
|
|
44
|
+
cancel(): void;
|
|
45
|
+
/** Attempts consumed so far, so a retry storm is observable rather than inferred. */
|
|
46
|
+
attempts(): number;
|
|
47
|
+
/** True once the per-session budget is spent. */
|
|
48
|
+
exhausted(): boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RetryRunnerOptions {
|
|
52
|
+
config: Required<BackoffConfig>;
|
|
53
|
+
/** Return true when the attempt succeeded and retrying should stop. */
|
|
54
|
+
attempt: (attemptNumber: number) => Promise<boolean>;
|
|
55
|
+
/** Called once the per-session budget is spent without success. */
|
|
56
|
+
onExhausted?: (attempts: number) => void;
|
|
57
|
+
/**
|
|
58
|
+
* Injected so a test can run the whole schedule synchronously instead of
|
|
59
|
+
* waiting minutes of real backoff. The handle is opaque, because a Node
|
|
60
|
+
* `Timeout` and a browser numeric id are both valid here.
|
|
61
|
+
*/
|
|
62
|
+
setTimeoutFn?: (fn: () => void, ms: number) => TimerHandle;
|
|
63
|
+
clearTimeoutFn?: (handle: TimerHandle) => void;
|
|
64
|
+
random?: () => number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Runs `attempt` under the bounded schedule.
|
|
69
|
+
*
|
|
70
|
+
* Exhaustion is reported rather than thrown: for finalization, a spent session
|
|
71
|
+
* budget is not a failure but a handoff to the next launch, and the caller is
|
|
72
|
+
* the only one that knows which of the two it is.
|
|
73
|
+
*/
|
|
74
|
+
export function runWithRetry(options: RetryRunnerOptions): RetryHandle {
|
|
75
|
+
const {
|
|
76
|
+
config,
|
|
77
|
+
attempt,
|
|
78
|
+
onExhausted,
|
|
79
|
+
setTimeoutFn = setTimeout,
|
|
80
|
+
clearTimeoutFn = clearTimeout,
|
|
81
|
+
random = Math.random,
|
|
82
|
+
} = options;
|
|
83
|
+
|
|
84
|
+
let attemptCount = 0;
|
|
85
|
+
let cancelled = false;
|
|
86
|
+
let pending: TimerHandle | undefined;
|
|
87
|
+
|
|
88
|
+
const schedule = (delayMs: number): void => {
|
|
89
|
+
pending = setTimeoutFn(() => {
|
|
90
|
+
pending = undefined;
|
|
91
|
+
run().catch(() => undefined);
|
|
92
|
+
}, delayMs);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const run = async (): Promise<void> => {
|
|
96
|
+
if (cancelled) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
attemptCount += 1;
|
|
100
|
+
let succeeded = false;
|
|
101
|
+
try {
|
|
102
|
+
succeeded = await attempt(attemptCount);
|
|
103
|
+
} catch {
|
|
104
|
+
// A throwing attempt is a failed attempt, nothing more. Letting it escape
|
|
105
|
+
// would abandon the schedule and, for finalization, silently drop a paid
|
|
106
|
+
// transaction.
|
|
107
|
+
succeeded = false;
|
|
108
|
+
}
|
|
109
|
+
if (cancelled || succeeded) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (attemptCount >= config.maxAttempts) {
|
|
113
|
+
onExhausted?.(attemptCount);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
schedule(backoffDelayMs(attemptCount + 1, config, random));
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Fire the first attempt immediately; the schedule owns everything after it.
|
|
120
|
+
run().catch(() => undefined);
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
cancel(): void {
|
|
124
|
+
cancelled = true;
|
|
125
|
+
if (pending !== undefined) {
|
|
126
|
+
clearTimeoutFn(pending);
|
|
127
|
+
pending = undefined;
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
attempts: () => attemptCount,
|
|
131
|
+
exhausted: () => attemptCount >= config.maxAttempts,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
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
|
+
|
|
38
|
+
export const CONNECT_TIMEOUT_BOUNDS = { min: 3_000, max: 60_000 } as const;
|
|
39
|
+
export const PRODUCT_QUERY_TIMEOUT_BOUNDS = { min: 3_000, max: 60_000 } as const;
|
|
40
|
+
export const SYNC_TIMEOUT_BOUNDS = { min: 5_000, max: 120_000 } as const;
|
|
41
|
+
export const PURCHASE_LAUNCH_TIMEOUT_BOUNDS = {
|
|
42
|
+
min: 5_000,
|
|
43
|
+
max: 120_000,
|
|
44
|
+
} as const;
|
|
45
|
+
export const FINALIZE_ATTEMPT_TIMEOUT_BOUNDS = {
|
|
46
|
+
min: 3_000,
|
|
47
|
+
max: 60_000,
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Prices and offer eligibility are shown at the moment of purchase, so a short
|
|
52
|
+
* TTL with on-demand refresh beats a long one (FR-029).
|
|
53
|
+
*/
|
|
54
|
+
export const DEFAULT_PRODUCT_CACHE_TTL_MS = 900_000;
|
|
55
|
+
export const PRODUCT_CACHE_TTL_BOUNDS = { min: 60_000, max: 86_400_000 } as const;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* How long a successful sync keeps entitlements trustworthy offline: **7 days**.
|
|
59
|
+
*
|
|
60
|
+
* Fixed by the specification (FR-055) after weighing both failure directions.
|
|
61
|
+
* The realistic offline cases — travel, poor coverage, a device left unused —
|
|
62
|
+
* resolve within days. Seven days covers a holiday without connectivity while
|
|
63
|
+
* bounding how long a refunded entitlement can outlive its refund to one week.
|
|
64
|
+
*
|
|
65
|
+
* Past this the entitlement becomes `unknown`, never `not_entitled` (FR-055a):
|
|
66
|
+
* the package stops asserting rather than telling a paying customer they did not
|
|
67
|
+
* pay.
|
|
68
|
+
*/
|
|
69
|
+
export const DEFAULT_STALE_AFTER_MS = 604_800_000;
|
|
70
|
+
export const STALE_AFTER_BOUNDS = {
|
|
71
|
+
min: 3_600_000,
|
|
72
|
+
max: 7_776_000_000,
|
|
73
|
+
} as const;
|
|
74
|
+
|
|
75
|
+
/** Keeps rapid app switching from becoming a request storm (PERF-002). */
|
|
76
|
+
export const DEFAULT_FOREGROUND_SYNC_MIN_INTERVAL_MS = 60_000;
|
|
77
|
+
export const FOREGROUND_SYNC_MIN_INTERVAL_BOUNDS = {
|
|
78
|
+
min: 0,
|
|
79
|
+
max: 3_600_000,
|
|
80
|
+
} as const;
|
|
81
|
+
|
|
82
|
+
/** Bounded so a dropped billing connection cannot become a retry storm (FR-020). */
|
|
83
|
+
export const DEFAULT_RECONNECT_BACKOFF: Required<BackoffConfig> = {
|
|
84
|
+
initialDelayMs: 1_000,
|
|
85
|
+
maxDelayMs: 30_000,
|
|
86
|
+
factor: 2,
|
|
87
|
+
maxAttempts: 5,
|
|
88
|
+
jitter: true,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Finalize retry.
|
|
93
|
+
*
|
|
94
|
+
* Bounded **per session** but never permanently abandoned: Google auto-refunds
|
|
95
|
+
* an unacknowledged purchase after three days, so exhausting this session's
|
|
96
|
+
* attempts must leave the transaction queued for the next launch, not dropped
|
|
97
|
+
* (FR-045).
|
|
98
|
+
*/
|
|
99
|
+
export const DEFAULT_FINALIZE_BACKOFF: Required<BackoffConfig> = {
|
|
100
|
+
initialDelayMs: 2_000,
|
|
101
|
+
maxDelayMs: 60_000,
|
|
102
|
+
factor: 2,
|
|
103
|
+
maxAttempts: 5,
|
|
104
|
+
jitter: true,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const BACKOFF_MAX_DELAY_BOUNDS = { min: 5_000, max: 600_000 } as const;
|
|
108
|
+
export const BACKOFF_ATTEMPT_BOUNDS = { min: 1, max: 10 } as const;
|
|
109
|
+
|
|
110
|
+
interface Bounds {
|
|
111
|
+
readonly min: number;
|
|
112
|
+
readonly max: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function requireInBounds(
|
|
116
|
+
label: string,
|
|
117
|
+
value: number | undefined,
|
|
118
|
+
fallback: number,
|
|
119
|
+
bounds: Bounds,
|
|
120
|
+
): number {
|
|
121
|
+
if (value === undefined) {
|
|
122
|
+
return fallback;
|
|
123
|
+
}
|
|
124
|
+
if (!Number.isFinite(value) || value < bounds.min || value > bounds.max) {
|
|
125
|
+
throw new PaymentConfigError({
|
|
126
|
+
code: 'PAYMENT_INVALID_CATALOG',
|
|
127
|
+
message: `${label} must be between ${bounds.min} and ${bounds.max}ms, received ${value}`,
|
|
128
|
+
operation: 'configure',
|
|
129
|
+
offenders: [label],
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface ResolvedTimeouts {
|
|
136
|
+
connectMs: number;
|
|
137
|
+
productQueryMs: number;
|
|
138
|
+
syncMs: number;
|
|
139
|
+
purchaseLaunchMs: number;
|
|
140
|
+
finalizeAttemptMs: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function resolveTimeouts(config?: TimeoutConfig): ResolvedTimeouts {
|
|
144
|
+
return {
|
|
145
|
+
connectMs: requireInBounds(
|
|
146
|
+
'timeouts.connectMs',
|
|
147
|
+
config?.connectMs,
|
|
148
|
+
DEFAULT_CONNECT_TIMEOUT_MS,
|
|
149
|
+
CONNECT_TIMEOUT_BOUNDS,
|
|
150
|
+
),
|
|
151
|
+
productQueryMs: requireInBounds(
|
|
152
|
+
'timeouts.productQueryMs',
|
|
153
|
+
config?.productQueryMs,
|
|
154
|
+
DEFAULT_PRODUCT_QUERY_TIMEOUT_MS,
|
|
155
|
+
PRODUCT_QUERY_TIMEOUT_BOUNDS,
|
|
156
|
+
),
|
|
157
|
+
syncMs: requireInBounds(
|
|
158
|
+
'timeouts.syncMs',
|
|
159
|
+
config?.syncMs,
|
|
160
|
+
DEFAULT_SYNC_TIMEOUT_MS,
|
|
161
|
+
SYNC_TIMEOUT_BOUNDS,
|
|
162
|
+
),
|
|
163
|
+
purchaseLaunchMs: requireInBounds(
|
|
164
|
+
'timeouts.purchaseLaunchMs',
|
|
165
|
+
config?.purchaseLaunchMs,
|
|
166
|
+
DEFAULT_PURCHASE_LAUNCH_TIMEOUT_MS,
|
|
167
|
+
PURCHASE_LAUNCH_TIMEOUT_BOUNDS,
|
|
168
|
+
),
|
|
169
|
+
finalizeAttemptMs: requireInBounds(
|
|
170
|
+
'timeouts.finalizeAttemptMs',
|
|
171
|
+
config?.finalizeAttemptMs,
|
|
172
|
+
DEFAULT_FINALIZE_ATTEMPT_TIMEOUT_MS,
|
|
173
|
+
FINALIZE_ATTEMPT_TIMEOUT_BOUNDS,
|
|
174
|
+
),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function resolveBackoff(
|
|
179
|
+
label: string,
|
|
180
|
+
config: BackoffConfig | undefined,
|
|
181
|
+
fallback: Required<BackoffConfig>,
|
|
182
|
+
): Required<BackoffConfig> {
|
|
183
|
+
const maxDelayMs = requireInBounds(
|
|
184
|
+
`${label}.maxDelayMs`,
|
|
185
|
+
config?.maxDelayMs,
|
|
186
|
+
fallback.maxDelayMs,
|
|
187
|
+
BACKOFF_MAX_DELAY_BOUNDS,
|
|
188
|
+
);
|
|
189
|
+
const maxAttempts = requireInBounds(
|
|
190
|
+
`${label}.maxAttempts`,
|
|
191
|
+
config?.maxAttempts,
|
|
192
|
+
fallback.maxAttempts,
|
|
193
|
+
BACKOFF_ATTEMPT_BOUNDS,
|
|
194
|
+
);
|
|
195
|
+
return {
|
|
196
|
+
initialDelayMs: config?.initialDelayMs ?? fallback.initialDelayMs,
|
|
197
|
+
maxDelayMs,
|
|
198
|
+
factor: config?.factor ?? fallback.factor,
|
|
199
|
+
maxAttempts,
|
|
200
|
+
jitter: config?.jitter ?? fallback.jitter,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface ResolvedRetry {
|
|
205
|
+
reconnect: Required<BackoffConfig>;
|
|
206
|
+
finalize: Required<BackoffConfig>;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function resolveRetry(config?: RetryConfig): ResolvedRetry {
|
|
210
|
+
return {
|
|
211
|
+
reconnect: resolveBackoff(
|
|
212
|
+
'retry.reconnect',
|
|
213
|
+
config?.reconnect,
|
|
214
|
+
DEFAULT_RECONNECT_BACKOFF,
|
|
215
|
+
),
|
|
216
|
+
finalize: resolveBackoff(
|
|
217
|
+
'retry.finalize',
|
|
218
|
+
config?.finalize,
|
|
219
|
+
DEFAULT_FINALIZE_BACKOFF,
|
|
220
|
+
),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function resolveStaleAfterMs(value?: number): number {
|
|
225
|
+
return requireInBounds(
|
|
226
|
+
'staleAfterMs',
|
|
227
|
+
value,
|
|
228
|
+
DEFAULT_STALE_AFTER_MS,
|
|
229
|
+
STALE_AFTER_BOUNDS,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function resolveProductCacheTtlMs(value?: number): number {
|
|
234
|
+
return requireInBounds(
|
|
235
|
+
'productCacheTtlMs',
|
|
236
|
+
value,
|
|
237
|
+
DEFAULT_PRODUCT_CACHE_TTL_MS,
|
|
238
|
+
PRODUCT_CACHE_TTL_BOUNDS,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function resolveForegroundSyncMinIntervalMs(value?: number): number {
|
|
243
|
+
return requireInBounds(
|
|
244
|
+
'foregroundSyncMinIntervalMs',
|
|
245
|
+
value,
|
|
246
|
+
DEFAULT_FOREGROUND_SYNC_MIN_INTERVAL_MS,
|
|
247
|
+
FOREGROUND_SYNC_MIN_INTERVAL_BOUNDS,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalized public errors.
|
|
3
|
+
*
|
|
4
|
+
* Error codes are **public API**: renaming one is a breaking change (FR-078,
|
|
5
|
+
* LIB-008). They are the vocabulary an app branches on, so they must outlive any
|
|
6
|
+
* internal refactor and must never leak a store's own code.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const PAYMENT_ERROR_CODES = [
|
|
10
|
+
'PAYMENT_NOT_CONFIGURED',
|
|
11
|
+
'PAYMENT_INVALID_CATALOG',
|
|
12
|
+
'PAYMENT_NOT_READY',
|
|
13
|
+
'PAYMENT_DISABLED',
|
|
14
|
+
'PAYMENT_UNAVAILABLE',
|
|
15
|
+
'PAYMENT_UNSUPPORTED_PLATFORM',
|
|
16
|
+
'PAYMENT_ENVIRONMENT_BLOCKED',
|
|
17
|
+
'PAYMENT_PRODUCTION_CONFIG_IN_NON_PRODUCTION_BUILD',
|
|
18
|
+
'PAYMENT_PURCHASE_IN_PROGRESS',
|
|
19
|
+
'PAYMENT_USER_CANCELLED',
|
|
20
|
+
'PAYMENT_PENDING',
|
|
21
|
+
'PAYMENT_ALREADY_OWNED',
|
|
22
|
+
'PAYMENT_INELIGIBLE',
|
|
23
|
+
'PAYMENT_UNKNOWN_PRODUCT',
|
|
24
|
+
'PAYMENT_PRODUCT_UNAVAILABLE_IN_REGION',
|
|
25
|
+
'PAYMENT_TIMEOUT',
|
|
26
|
+
'PAYMENT_NETWORK_ERROR',
|
|
27
|
+
'PAYMENT_STORE_ERROR',
|
|
28
|
+
'PAYMENT_FINALIZE_FAILED',
|
|
29
|
+
'PAYMENT_VALIDATION_REJECTED',
|
|
30
|
+
'PAYMENT_VALIDATION_UNAVAILABLE',
|
|
31
|
+
'PAYMENT_STORAGE_UNAVAILABLE',
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
export type PaymentErrorCode = (typeof PAYMENT_ERROR_CODES)[number];
|
|
35
|
+
|
|
36
|
+
/** Which operation produced an error. Part of the public error shape (FR-078). */
|
|
37
|
+
export type PaymentOperation =
|
|
38
|
+
| 'configure'
|
|
39
|
+
| 'initialize'
|
|
40
|
+
| 'getProducts'
|
|
41
|
+
| 'purchase'
|
|
42
|
+
| 'finalize'
|
|
43
|
+
| 'consume'
|
|
44
|
+
| 'sync'
|
|
45
|
+
| 'restore'
|
|
46
|
+
| 'changePlan'
|
|
47
|
+
| 'validate'
|
|
48
|
+
| 'persist';
|
|
49
|
+
|
|
50
|
+
export class PaymentError extends Error {
|
|
51
|
+
readonly code: PaymentErrorCode;
|
|
52
|
+
readonly operation: PaymentOperation;
|
|
53
|
+
readonly retryable: boolean;
|
|
54
|
+
/** Redacted where appropriate — never a raw receipt, token, or store object (FR-074). */
|
|
55
|
+
readonly cause?: string;
|
|
56
|
+
|
|
57
|
+
constructor(init: {
|
|
58
|
+
code: PaymentErrorCode;
|
|
59
|
+
message: string;
|
|
60
|
+
operation: PaymentOperation;
|
|
61
|
+
retryable?: boolean;
|
|
62
|
+
cause?: string;
|
|
63
|
+
}) {
|
|
64
|
+
super(init.message);
|
|
65
|
+
this.name = 'PaymentError';
|
|
66
|
+
this.code = init.code;
|
|
67
|
+
this.operation = init.operation;
|
|
68
|
+
this.retryable = init.retryable ?? false;
|
|
69
|
+
this.cause = init.cause;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A configuration problem the app can act on.
|
|
75
|
+
*
|
|
76
|
+
* Separate from `PaymentError` because configuration faults are developer-facing
|
|
77
|
+
* and must never be shown to an end user — the message-key map keeps that
|
|
78
|
+
* separation explicit (FR-085).
|
|
79
|
+
*/
|
|
80
|
+
export class PaymentConfigError extends PaymentError {
|
|
81
|
+
/** The offending catalog entries or entitlement keys, named (FR-011). */
|
|
82
|
+
readonly offenders: string[];
|
|
83
|
+
|
|
84
|
+
constructor(init: {
|
|
85
|
+
code: PaymentErrorCode;
|
|
86
|
+
message: string;
|
|
87
|
+
operation: PaymentOperation;
|
|
88
|
+
offenders?: string[];
|
|
89
|
+
}) {
|
|
90
|
+
super({ ...init, retryable: false });
|
|
91
|
+
this.name = 'PaymentConfigError';
|
|
92
|
+
this.offenders = init.offenders ?? [];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Strips anything that must never reach a log or event.
|
|
98
|
+
*
|
|
99
|
+
* Applied at the boundary rather than trusted to call sites: a single forgotten
|
|
100
|
+
* interpolation is how receipts end up in logs (NFR-006).
|
|
101
|
+
*/
|
|
102
|
+
export function redact(value: unknown): string {
|
|
103
|
+
if (value instanceof Error) {
|
|
104
|
+
return value.message;
|
|
105
|
+
}
|
|
106
|
+
if (typeof value === 'string') {
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
return '[redacted]';
|
|
110
|
+
}
|