@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,266 +1,282 @@
1
- /**
2
- * Typed events and the emitter.
3
- *
4
- * The bar is FR-077: the event stream **alone** must be enough to reconstruct a
5
- * transaction's full lifecycle, so that "the user says they paid but has no
6
- * entitlement" can be investigated from logs without a reproduction.
7
- *
8
- * Forbidden in every payload, without exception (FR-076, FR-074): store objects,
9
- * raw receipts, verification payloads, backend tokens, payment details, the
10
- * obfuscated account identifier, and the cache-scope token.
11
- */
12
-
13
- import { redact, type PaymentErrorCode, type PaymentOperation } from './errors';
14
- import type {
15
- EntitlementKey,
16
- Environment,
17
- PendingResolution,
18
- ProductKey,
19
- Unsubscribe,
20
- UnavailableReason,
21
- PaymentStatusKind,
22
- } from '../config/types';
23
-
24
- export type PaymentEventType =
25
- | 'initialization'
26
- | 'connection_changed'
27
- | 'products_queried'
28
- | 'purchase_started'
29
- | 'purchase_succeeded'
30
- | 'purchase_cancelled'
31
- | 'purchase_pending'
32
- // How a pending purchase ended, once per transaction. `settled` precedes the
33
- // `entitlement_granted` it leads to (FR-102), added in 0.2.0.
34
- | 'purchase_pending_resolved'
35
- | 'purchase_failed'
36
- | 'transaction_finalized'
37
- | 'transaction_consumed'
38
- | 'finalize_retry_scheduled'
39
- | 'sync_completed'
40
- | 'entitlement_granted'
41
- | 'entitlement_revoked'
42
- | 'validation_decided'
43
- | 'validation_unavailable'
44
- | 'environment_blocked'
45
- | 'config_warning'
46
- | 'internal_error';
47
-
48
- interface BaseEvent {
49
- timestamp: number;
50
- /** Ties one purchase cycle together end to end. Absent for lifecycle events. */
51
- correlationId?: string;
52
- }
53
-
54
- export type SyncTrigger = 'initialize' | 'foreground' | 'manual' | 'already_owned';
55
-
56
- export type PaymentEvent = BaseEvent &
57
- (
58
- | {
59
- type: 'initialization';
60
- status: PaymentStatusKind;
61
- unavailableReason?: UnavailableReason;
62
- retryable?: boolean;
63
- }
64
- | {
65
- type: 'connection_changed';
66
- connected: boolean;
67
- reason?: string;
68
- attempt?: number;
69
- }
70
- | {
71
- type: 'products_queried';
72
- productKeys: ProductKey[];
73
- found: number;
74
- missing: ProductKey[];
75
- fromCache: boolean;
76
- }
77
- | { type: 'purchase_started'; productKey: ProductKey }
78
- | { type: 'purchase_succeeded'; productKey: ProductKey }
79
- | { type: 'purchase_cancelled'; productKey: ProductKey }
80
- | { type: 'purchase_pending'; productKey: ProductKey }
81
- | {
82
- type: 'purchase_pending_resolved';
83
- productKey: ProductKey;
84
- outcome: PendingResolution;
85
- }
86
- | {
87
- type: 'purchase_failed';
88
- productKey: ProductKey;
89
- code: PaymentErrorCode;
90
- retryable: boolean;
91
- }
92
- | {
93
- type: 'transaction_finalized';
94
- productKey: ProductKey;
95
- attempts: number;
96
- }
97
- | { type: 'transaction_consumed'; productKey: ProductKey }
98
- | {
99
- type: 'finalize_retry_scheduled';
100
- productKey: ProductKey;
101
- attempt: number;
102
- delayMs: number;
103
- }
104
- | {
105
- type: 'sync_completed';
106
- restoredCount: number;
107
- revokedCount: number;
108
- trigger: SyncTrigger;
109
- }
110
- | {
111
- type: 'entitlement_granted';
112
- entitlementKey: EntitlementKey;
113
- sources: ProductKey[];
114
- }
115
- | {
116
- type: 'entitlement_revoked';
117
- entitlementKey: EntitlementKey;
118
- reason: 'expired' | 'revoked_by_store' | 'validation_rejected' | 'consumed';
119
- }
120
- | {
121
- type: 'validation_decided';
122
- productKey: ProductKey;
123
- decision: 'approved' | 'rejected';
124
- }
125
- | {
126
- type: 'validation_unavailable';
127
- productKey: ProductKey;
128
- fallback: 'grant_and_queue' | 'withhold';
129
- }
130
- | {
131
- type: 'environment_blocked';
132
- productKey: ProductKey;
133
- environment: Environment;
134
- }
135
- | { type: 'config_warning'; detail: string }
136
- | {
137
- type: 'internal_error';
138
- code: PaymentErrorCode;
139
- operation: PaymentOperation;
140
- }
141
- );
142
-
143
- export type PaymentEventListener = (event: PaymentEvent) => void;
144
-
145
- /**
146
- * A `PaymentEvent` with the timestamp left to the emitter.
147
- *
148
- * Distributive on purpose: a plain `Omit<PaymentEvent, 'timestamp'>` collapses the
149
- * discriminated union into one object type with only the shared keys, which would
150
- * reject every variant-specific field. `T extends unknown` forces the mapping to
151
- * apply per member and keeps the union discriminated.
152
- */
153
- type WithOptionalTimestamp<T> = T extends unknown
154
- ? Omit<T, 'timestamp'> & { timestamp?: number }
155
- : never;
156
-
157
- export type PaymentEventInput = WithOptionalTimestamp<PaymentEvent>;
158
-
159
- export interface PaymentEmitter {
160
- emit(event: PaymentEvent): void;
161
- subscribe(listener: PaymentEventListener): Unsubscribe;
162
- /** Disarms permanently. After this nothing is emitted (FR-082). */
163
- dispose(): void;
164
- listenerCount(): number;
165
- disposed(): boolean;
166
- }
167
-
168
- /**
169
- * Creates the emitter.
170
- *
171
- * Two properties are deliberate:
172
- *
173
- * 1. **A throwing listener cannot break anything** (FR-081). Each listener is
174
- * invoked defensively, the throw is handed to `onListenerError`, and delivery
175
- * continues to the remaining listeners. One app's broken analytics callback
176
- * must not stop the package granting an entitlement or stop another listener
177
- * hearing about it.
178
- *
179
- * The failure is **not** re-emitted as an event, deliberately. A consumer's own
180
- * bug is not a payment-lifecycle event, and reporting it through the channel
181
- * that just failed would both invite recursion and double event traffic for
182
- * every event while that listener stays broken. It goes to the logger instead.
183
- *
184
- * 2. **After `dispose`, nothing is emitted** (FR-082). A store callback can
185
- * arrive after cleanup; it must be dropped silently rather than reach a
186
- * listener whose component is gone.
187
- */
188
- export function createEmitter(
189
- onListenerError?: (error: unknown, event: PaymentEvent) => void,
190
- ): PaymentEmitter {
191
- const listeners = new Set<PaymentEventListener>();
192
- let isDisposed = false;
193
-
194
- const emit = (event: PaymentEvent): void => {
195
- if (isDisposed) {
196
- return;
197
- }
198
- // Snapshot first: a listener that subscribes or unsubscribes during
199
- // delivery must not mutate the set being iterated.
200
- for (const listener of [...listeners]) {
201
- try {
202
- listener(event);
203
- } catch (error) {
204
- try {
205
- onListenerError?.(error, event);
206
- } catch {
207
- // Even the error reporter is not trusted to behave.
208
- }
209
- }
210
- }
211
- };
212
-
213
- return {
214
- emit,
215
- subscribe(listener: PaymentEventListener): Unsubscribe {
216
- if (isDisposed) {
217
- return () => undefined;
218
- }
219
- listeners.add(listener);
220
- let removed = false;
221
- return () => {
222
- if (removed) {
223
- return;
224
- }
225
- removed = true;
226
- listeners.delete(listener);
227
- };
228
- },
229
- dispose(): void {
230
- isDisposed = true;
231
- listeners.clear();
232
- },
233
- listenerCount: () => listeners.size,
234
- disposed: () => isDisposed,
235
- };
236
- }
237
-
238
- /**
239
- * Fields that must never appear in an event payload.
240
- *
241
- * Used by `__tests__/redaction.test.ts` to walk every emitted event across the
242
- * whole suite. Enumerating the ban here rather than in the test keeps the rule
243
- * next to the type it constrains.
244
- */
245
- export const FORBIDDEN_EVENT_FIELDS: readonly string[] = [
246
- 'verificationPayload',
247
- 'receipt',
248
- 'purchaseToken',
249
- 'transactionReceipt',
250
- 'obfuscatedAccountId',
251
- 'scopeToken',
252
- 'token',
253
- 'signature',
254
- ];
255
-
256
- /** Correlation ids tie one purchase cycle together; no user data goes into them. */
257
- export function createCorrelationId(
258
- counter: { next: number },
259
- prefix = 'pc',
260
- ): string {
261
- const id = `${prefix}_${counter.next.toString(36)}`;
262
- counter.next += 1;
263
- return id;
264
- }
265
-
266
- export { redact };
1
+ /**
2
+ * Typed events and the emitter.
3
+ *
4
+ * The bar is FR-077: the event stream **alone** must be enough to reconstruct a
5
+ * transaction's full lifecycle, so that "the user says they paid but has no
6
+ * entitlement" can be investigated from logs without a reproduction.
7
+ *
8
+ * Forbidden in every payload, without exception (FR-076, FR-074): store objects,
9
+ * raw receipts, verification payloads, backend tokens, payment details, the
10
+ * obfuscated account identifier, and the cache-scope token.
11
+ */
12
+
13
+ import {
14
+ createDiagnosticChannel,
15
+ createEmitter as createCoreEmitter,
16
+ } from '@chipmobilesdk/rn-core';
17
+
18
+ /** Every delivered event also goes to the app's SDK sinks (`installSdkSink`, CORE-08). */
19
+ const channel = createDiagnosticChannel<PaymentEvent['type'] | 'listener_reporter_failed'>(
20
+ 'rn-payment',
21
+ );
22
+
23
+ import { redact, type PaymentErrorCode, type PaymentOperation } from './errors';
24
+ import type {
25
+ EntitlementKey,
26
+ Environment,
27
+ PendingResolution,
28
+ ProductKey,
29
+ Unsubscribe,
30
+ UnavailableReason,
31
+ PaymentStatusKind,
32
+ } from '../config/types';
33
+
34
+ export type PaymentEventType =
35
+ | 'initialization'
36
+ | 'connection_changed'
37
+ | 'products_queried'
38
+ | 'purchase_started'
39
+ | 'purchase_succeeded'
40
+ | 'purchase_cancelled'
41
+ | 'purchase_pending'
42
+ // How a pending purchase ended, once per transaction. `settled` precedes the
43
+ // `entitlement_granted` it leads to (FR-102), added in 0.2.0.
44
+ | 'purchase_pending_resolved'
45
+ | 'purchase_failed'
46
+ | 'transaction_finalized'
47
+ | 'transaction_consumed'
48
+ | 'finalize_retry_scheduled'
49
+ | 'sync_completed'
50
+ | 'entitlement_granted'
51
+ | 'entitlement_revoked'
52
+ | 'validation_decided'
53
+ | 'validation_unavailable'
54
+ | 'environment_blocked'
55
+ | 'config_warning'
56
+ | 'internal_error';
57
+
58
+ interface BaseEvent {
59
+ timestamp: number;
60
+ /** Ties one purchase cycle together end to end. Absent for lifecycle events. */
61
+ correlationId?: string;
62
+ }
63
+
64
+ export type SyncTrigger = 'initialize' | 'foreground' | 'manual' | 'already_owned';
65
+
66
+ export type PaymentEvent = BaseEvent &
67
+ (
68
+ | {
69
+ type: 'initialization';
70
+ status: PaymentStatusKind;
71
+ unavailableReason?: UnavailableReason;
72
+ retryable?: boolean;
73
+ }
74
+ | {
75
+ type: 'connection_changed';
76
+ connected: boolean;
77
+ reason?: string;
78
+ attempt?: number;
79
+ }
80
+ | {
81
+ type: 'products_queried';
82
+ productKeys: ProductKey[];
83
+ found: number;
84
+ missing: ProductKey[];
85
+ fromCache: boolean;
86
+ }
87
+ | { type: 'purchase_started'; productKey: ProductKey }
88
+ | { type: 'purchase_succeeded'; productKey: ProductKey }
89
+ | { type: 'purchase_cancelled'; productKey: ProductKey }
90
+ | { type: 'purchase_pending'; productKey: ProductKey }
91
+ | {
92
+ type: 'purchase_pending_resolved';
93
+ productKey: ProductKey;
94
+ outcome: PendingResolution;
95
+ }
96
+ | {
97
+ type: 'purchase_failed';
98
+ productKey: ProductKey;
99
+ code: PaymentErrorCode;
100
+ retryable: boolean;
101
+ }
102
+ | {
103
+ type: 'transaction_finalized';
104
+ productKey: ProductKey;
105
+ attempts: number;
106
+ }
107
+ | { type: 'transaction_consumed'; productKey: ProductKey }
108
+ | {
109
+ type: 'finalize_retry_scheduled';
110
+ productKey: ProductKey;
111
+ attempt: number;
112
+ delayMs: number;
113
+ }
114
+ | {
115
+ type: 'sync_completed';
116
+ restoredCount: number;
117
+ revokedCount: number;
118
+ trigger: SyncTrigger;
119
+ }
120
+ | {
121
+ type: 'entitlement_granted';
122
+ entitlementKey: EntitlementKey;
123
+ sources: ProductKey[];
124
+ }
125
+ | {
126
+ type: 'entitlement_revoked';
127
+ entitlementKey: EntitlementKey;
128
+ reason: 'expired' | 'revoked_by_store' | 'validation_rejected' | 'consumed';
129
+ }
130
+ | {
131
+ type: 'validation_decided';
132
+ productKey: ProductKey;
133
+ decision: 'approved' | 'rejected';
134
+ }
135
+ | {
136
+ type: 'validation_unavailable';
137
+ productKey: ProductKey;
138
+ fallback: 'grant_and_queue' | 'withhold';
139
+ }
140
+ | {
141
+ type: 'environment_blocked';
142
+ productKey: ProductKey;
143
+ environment: Environment;
144
+ }
145
+ | { type: 'config_warning'; detail: string }
146
+ | {
147
+ type: 'internal_error';
148
+ code: PaymentErrorCode;
149
+ operation: PaymentOperation;
150
+ }
151
+ );
152
+
153
+ export type PaymentEventListener = (event: PaymentEvent) => void;
154
+
155
+ /**
156
+ * A `PaymentEvent` with the timestamp left to the emitter.
157
+ *
158
+ * Distributive on purpose: a plain `Omit<PaymentEvent, 'timestamp'>` collapses the
159
+ * discriminated union into one object type with only the shared keys, which would
160
+ * reject every variant-specific field. `T extends unknown` forces the mapping to
161
+ * apply per member and keeps the union discriminated.
162
+ */
163
+ type WithOptionalTimestamp<T> = T extends unknown
164
+ ? Omit<T, 'timestamp'> & { timestamp?: number }
165
+ : never;
166
+
167
+ export type PaymentEventInput = WithOptionalTimestamp<PaymentEvent>;
168
+
169
+ export interface PaymentEmitter {
170
+ emit(event: PaymentEvent): void;
171
+ subscribe(listener: PaymentEventListener): Unsubscribe;
172
+ /** Disarms permanently. After this nothing is emitted (FR-082). */
173
+ dispose(): void;
174
+ listenerCount(): number;
175
+ disposed(): boolean;
176
+ }
177
+
178
+ /**
179
+ * Creates the emitter.
180
+ *
181
+ * Two properties are deliberate:
182
+ *
183
+ * 1. **A throwing listener cannot break anything** (FR-081). Each listener is
184
+ * invoked defensively, the throw is handed to `onListenerError`, and delivery
185
+ * continues to the remaining listeners. One app's broken analytics callback
186
+ * must not stop the package granting an entitlement or stop another listener
187
+ * hearing about it.
188
+ *
189
+ * The failure is **not** re-emitted as an event, deliberately. A consumer's own
190
+ * bug is not a payment-lifecycle event, and reporting it through the channel
191
+ * that just failed would both invite recursion and double event traffic for
192
+ * every event while that listener stays broken. It goes to the logger instead.
193
+ *
194
+ * 2. **After `dispose`, nothing is emitted** (FR-082). A store callback can
195
+ * arrive after cleanup; it must be dropped silently rather than reach a
196
+ * listener whose component is gone.
197
+ */
198
+ export function createEmitter(
199
+ onListenerError?: (error: unknown, event: PaymentEvent) => void,
200
+ ): PaymentEmitter {
201
+ // The event whose listeners are running, so a failure is reported against it.
202
+ let dispatching: PaymentEvent | null = null;
203
+ let isDisposed = false;
204
+ // rn-core's emitter: a snapshot per emit, each listener isolated, idempotent
205
+ // unsubscribe. Even the error reporter is isolated: if it throws, rn-core
206
+ // rethrows that on a fresh task rather than into the emit.
207
+ const emitter = createCoreEmitter<PaymentEvent>({
208
+ onListenerError: error => {
209
+ if (!dispatching) return;
210
+ try {
211
+ onListenerError?.(error, dispatching);
212
+ } catch {
213
+ // The app's own reporter failed: nothing in the app is left to tell, so
214
+ // the failure goes to the SDK sinks rather than becoming an uncaught
215
+ // error on some later tick (FR-080).
216
+ channel.emit('listener_reporter_failed', 'error', {
217
+ eventType: dispatching.type,
218
+ });
219
+ }
220
+ },
221
+ });
222
+
223
+ return {
224
+ emit(event) {
225
+ if (isDisposed) {
226
+ return;
227
+ }
228
+ const previous = dispatching;
229
+ dispatching = event;
230
+ try {
231
+ if (emitter.emit(event)) {
232
+ // The type and the cycle only: payment events carry product and
233
+ // entitlement keys an app may treat as personal.
234
+ channel.emit(
235
+ event.type,
236
+ /failed|error|blocked|rejected|revoked/.test(event.type) ? 'warn' : 'info',
237
+ undefined,
238
+ event.correlationId,
239
+ );
240
+ }
241
+ } finally {
242
+ dispatching = previous;
243
+ }
244
+ },
245
+ subscribe(listener: PaymentEventListener): Unsubscribe {
246
+ return isDisposed ? () => undefined : emitter.subscribe(listener);
247
+ },
248
+ dispose(): void {
249
+ isDisposed = true;
250
+ emitter.dispose();
251
+ },
252
+ listenerCount: () => emitter.listenerCount(),
253
+ disposed: () => isDisposed,
254
+ };
255
+ }
256
+
257
+ /**
258
+ * Fields that must never appear in an event payload.
259
+ *
260
+ * Used by `__tests__/redaction.test.ts` to walk every emitted event across the
261
+ * whole suite. Enumerating the ban here rather than in the test keeps the rule
262
+ * next to the type it constrains.
263
+ */
264
+ export const FORBIDDEN_EVENT_FIELDS: readonly string[] = [
265
+ 'verificationPayload',
266
+ 'receipt',
267
+ 'purchaseToken',
268
+ 'transactionReceipt',
269
+ 'obfuscatedAccountId',
270
+ 'scopeToken',
271
+ 'token',
272
+ 'signature',
273
+ ];
274
+
275
+ /** Correlation ids tie one purchase cycle together; no user data goes into them. */
276
+ export function createCorrelationId(counter: { next: number }, prefix = 'pc'): string {
277
+ const id = `${prefix}_${counter.next.toString(36)}`;
278
+ counter.next += 1;
279
+ return id;
280
+ }
281
+
282
+ export { redact };