@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,257 @@
|
|
|
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
|
+
ProductKey,
|
|
18
|
+
Unsubscribe,
|
|
19
|
+
UnavailableReason,
|
|
20
|
+
PaymentStatusKind,
|
|
21
|
+
} from '../config/types';
|
|
22
|
+
|
|
23
|
+
export type PaymentEventType =
|
|
24
|
+
| 'initialization'
|
|
25
|
+
| 'connection_changed'
|
|
26
|
+
| 'products_queried'
|
|
27
|
+
| 'purchase_started'
|
|
28
|
+
| 'purchase_succeeded'
|
|
29
|
+
| 'purchase_cancelled'
|
|
30
|
+
| 'purchase_pending'
|
|
31
|
+
| 'purchase_failed'
|
|
32
|
+
| 'transaction_finalized'
|
|
33
|
+
| 'transaction_consumed'
|
|
34
|
+
| 'finalize_retry_scheduled'
|
|
35
|
+
| 'sync_completed'
|
|
36
|
+
| 'entitlement_granted'
|
|
37
|
+
| 'entitlement_revoked'
|
|
38
|
+
| 'validation_decided'
|
|
39
|
+
| 'validation_unavailable'
|
|
40
|
+
| 'environment_blocked'
|
|
41
|
+
| 'config_warning'
|
|
42
|
+
| 'internal_error';
|
|
43
|
+
|
|
44
|
+
interface BaseEvent {
|
|
45
|
+
timestamp: number;
|
|
46
|
+
/** Ties one purchase cycle together end to end. Absent for lifecycle events. */
|
|
47
|
+
correlationId?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type SyncTrigger = 'initialize' | 'foreground' | 'manual' | 'already_owned';
|
|
51
|
+
|
|
52
|
+
export type PaymentEvent = BaseEvent &
|
|
53
|
+
(
|
|
54
|
+
| {
|
|
55
|
+
type: 'initialization';
|
|
56
|
+
status: PaymentStatusKind;
|
|
57
|
+
unavailableReason?: UnavailableReason;
|
|
58
|
+
retryable?: boolean;
|
|
59
|
+
}
|
|
60
|
+
| {
|
|
61
|
+
type: 'connection_changed';
|
|
62
|
+
connected: boolean;
|
|
63
|
+
reason?: string;
|
|
64
|
+
attempt?: number;
|
|
65
|
+
}
|
|
66
|
+
| {
|
|
67
|
+
type: 'products_queried';
|
|
68
|
+
productKeys: ProductKey[];
|
|
69
|
+
found: number;
|
|
70
|
+
missing: ProductKey[];
|
|
71
|
+
fromCache: boolean;
|
|
72
|
+
}
|
|
73
|
+
| { type: 'purchase_started'; productKey: ProductKey }
|
|
74
|
+
| { type: 'purchase_succeeded'; productKey: ProductKey }
|
|
75
|
+
| { type: 'purchase_cancelled'; productKey: ProductKey }
|
|
76
|
+
| { type: 'purchase_pending'; productKey: ProductKey }
|
|
77
|
+
| {
|
|
78
|
+
type: 'purchase_failed';
|
|
79
|
+
productKey: ProductKey;
|
|
80
|
+
code: PaymentErrorCode;
|
|
81
|
+
retryable: boolean;
|
|
82
|
+
}
|
|
83
|
+
| {
|
|
84
|
+
type: 'transaction_finalized';
|
|
85
|
+
productKey: ProductKey;
|
|
86
|
+
attempts: number;
|
|
87
|
+
}
|
|
88
|
+
| { type: 'transaction_consumed'; productKey: ProductKey }
|
|
89
|
+
| {
|
|
90
|
+
type: 'finalize_retry_scheduled';
|
|
91
|
+
productKey: ProductKey;
|
|
92
|
+
attempt: number;
|
|
93
|
+
delayMs: number;
|
|
94
|
+
}
|
|
95
|
+
| {
|
|
96
|
+
type: 'sync_completed';
|
|
97
|
+
restoredCount: number;
|
|
98
|
+
revokedCount: number;
|
|
99
|
+
trigger: SyncTrigger;
|
|
100
|
+
}
|
|
101
|
+
| {
|
|
102
|
+
type: 'entitlement_granted';
|
|
103
|
+
entitlementKey: EntitlementKey;
|
|
104
|
+
sources: ProductKey[];
|
|
105
|
+
}
|
|
106
|
+
| {
|
|
107
|
+
type: 'entitlement_revoked';
|
|
108
|
+
entitlementKey: EntitlementKey;
|
|
109
|
+
reason: 'expired' | 'revoked_by_store' | 'validation_rejected' | 'consumed';
|
|
110
|
+
}
|
|
111
|
+
| {
|
|
112
|
+
type: 'validation_decided';
|
|
113
|
+
productKey: ProductKey;
|
|
114
|
+
decision: 'approved' | 'rejected';
|
|
115
|
+
}
|
|
116
|
+
| {
|
|
117
|
+
type: 'validation_unavailable';
|
|
118
|
+
productKey: ProductKey;
|
|
119
|
+
fallback: 'grant_and_queue' | 'withhold';
|
|
120
|
+
}
|
|
121
|
+
| {
|
|
122
|
+
type: 'environment_blocked';
|
|
123
|
+
productKey: ProductKey;
|
|
124
|
+
environment: Environment;
|
|
125
|
+
}
|
|
126
|
+
| { type: 'config_warning'; detail: string }
|
|
127
|
+
| {
|
|
128
|
+
type: 'internal_error';
|
|
129
|
+
code: PaymentErrorCode;
|
|
130
|
+
operation: PaymentOperation;
|
|
131
|
+
}
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
export type PaymentEventListener = (event: PaymentEvent) => void;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A `PaymentEvent` with the timestamp left to the emitter.
|
|
138
|
+
*
|
|
139
|
+
* Distributive on purpose: a plain `Omit<PaymentEvent, 'timestamp'>` collapses the
|
|
140
|
+
* discriminated union into one object type with only the shared keys, which would
|
|
141
|
+
* reject every variant-specific field. `T extends unknown` forces the mapping to
|
|
142
|
+
* apply per member and keeps the union discriminated.
|
|
143
|
+
*/
|
|
144
|
+
type WithOptionalTimestamp<T> = T extends unknown
|
|
145
|
+
? Omit<T, 'timestamp'> & { timestamp?: number }
|
|
146
|
+
: never;
|
|
147
|
+
|
|
148
|
+
export type PaymentEventInput = WithOptionalTimestamp<PaymentEvent>;
|
|
149
|
+
|
|
150
|
+
export interface PaymentEmitter {
|
|
151
|
+
emit(event: PaymentEvent): void;
|
|
152
|
+
subscribe(listener: PaymentEventListener): Unsubscribe;
|
|
153
|
+
/** Disarms permanently. After this nothing is emitted (FR-082). */
|
|
154
|
+
dispose(): void;
|
|
155
|
+
listenerCount(): number;
|
|
156
|
+
disposed(): boolean;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Creates the emitter.
|
|
161
|
+
*
|
|
162
|
+
* Two properties are deliberate:
|
|
163
|
+
*
|
|
164
|
+
* 1. **A throwing listener cannot break anything** (FR-081). Each listener is
|
|
165
|
+
* invoked defensively, the throw is handed to `onListenerError`, and delivery
|
|
166
|
+
* continues to the remaining listeners. One app's broken analytics callback
|
|
167
|
+
* must not stop the package granting an entitlement or stop another listener
|
|
168
|
+
* hearing about it.
|
|
169
|
+
*
|
|
170
|
+
* The failure is **not** re-emitted as an event, deliberately. A consumer's own
|
|
171
|
+
* bug is not a payment-lifecycle event, and reporting it through the channel
|
|
172
|
+
* that just failed would both invite recursion and double event traffic for
|
|
173
|
+
* every event while that listener stays broken. It goes to the logger instead.
|
|
174
|
+
*
|
|
175
|
+
* 2. **After `dispose`, nothing is emitted** (FR-082). A store callback can
|
|
176
|
+
* arrive after cleanup; it must be dropped silently rather than reach a
|
|
177
|
+
* listener whose component is gone.
|
|
178
|
+
*/
|
|
179
|
+
export function createEmitter(
|
|
180
|
+
onListenerError?: (error: unknown, event: PaymentEvent) => void,
|
|
181
|
+
): PaymentEmitter {
|
|
182
|
+
const listeners = new Set<PaymentEventListener>();
|
|
183
|
+
let isDisposed = false;
|
|
184
|
+
|
|
185
|
+
const emit = (event: PaymentEvent): void => {
|
|
186
|
+
if (isDisposed) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
// Snapshot first: a listener that subscribes or unsubscribes during
|
|
190
|
+
// delivery must not mutate the set being iterated.
|
|
191
|
+
for (const listener of [...listeners]) {
|
|
192
|
+
try {
|
|
193
|
+
listener(event);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
try {
|
|
196
|
+
onListenerError?.(error, event);
|
|
197
|
+
} catch {
|
|
198
|
+
// Even the error reporter is not trusted to behave.
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
emit,
|
|
206
|
+
subscribe(listener: PaymentEventListener): Unsubscribe {
|
|
207
|
+
if (isDisposed) {
|
|
208
|
+
return () => undefined;
|
|
209
|
+
}
|
|
210
|
+
listeners.add(listener);
|
|
211
|
+
let removed = false;
|
|
212
|
+
return () => {
|
|
213
|
+
if (removed) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
removed = true;
|
|
217
|
+
listeners.delete(listener);
|
|
218
|
+
};
|
|
219
|
+
},
|
|
220
|
+
dispose(): void {
|
|
221
|
+
isDisposed = true;
|
|
222
|
+
listeners.clear();
|
|
223
|
+
},
|
|
224
|
+
listenerCount: () => listeners.size,
|
|
225
|
+
disposed: () => isDisposed,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Fields that must never appear in an event payload.
|
|
231
|
+
*
|
|
232
|
+
* Used by `__tests__/redaction.test.ts` to walk every emitted event across the
|
|
233
|
+
* whole suite. Enumerating the ban here rather than in the test keeps the rule
|
|
234
|
+
* next to the type it constrains.
|
|
235
|
+
*/
|
|
236
|
+
export const FORBIDDEN_EVENT_FIELDS: readonly string[] = [
|
|
237
|
+
'verificationPayload',
|
|
238
|
+
'receipt',
|
|
239
|
+
'purchaseToken',
|
|
240
|
+
'transactionReceipt',
|
|
241
|
+
'obfuscatedAccountId',
|
|
242
|
+
'scopeToken',
|
|
243
|
+
'token',
|
|
244
|
+
'signature',
|
|
245
|
+
];
|
|
246
|
+
|
|
247
|
+
/** Correlation ids tie one purchase cycle together; no user data goes into them. */
|
|
248
|
+
export function createCorrelationId(
|
|
249
|
+
counter: { next: number },
|
|
250
|
+
prefix = 'pc',
|
|
251
|
+
): string {
|
|
252
|
+
const id = `${prefix}_${counter.next.toString(36)}`;
|
|
253
|
+
counter.next += 1;
|
|
254
|
+
return id;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export { redact };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable, localizable message keys with English default copy (FR-084, FR-085).
|
|
3
|
+
*
|
|
4
|
+
* The package takes no i18n runtime dependency: consuming apps map these keys to
|
|
5
|
+
* localized strings through whatever i18n system they already run. This is plain
|
|
6
|
+
* data, and the package never resolves or renders it.
|
|
7
|
+
*
|
|
8
|
+
* Message keys are **public API**: renaming one is a breaking change, on the
|
|
9
|
+
* same footing as an error code (FR-078).
|
|
10
|
+
*
|
|
11
|
+
* User-actionable copy and developer/configuration copy are kept in separate
|
|
12
|
+
* maps so an app cannot accidentally show "Payment catalog is invalid" to a
|
|
13
|
+
* customer — a configuration fault is a developer's problem, not a user's.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Copy an end user may reasonably be shown. */
|
|
17
|
+
export interface PaymentUserMessageMap {
|
|
18
|
+
'payment.state.pending': string;
|
|
19
|
+
'payment.state.alreadyOwned': string;
|
|
20
|
+
'payment.action.restore': string;
|
|
21
|
+
'payment.restore.succeeded': string;
|
|
22
|
+
'payment.restore.nothingFound': string;
|
|
23
|
+
'payment.error.cancelled': string;
|
|
24
|
+
'payment.error.network': string;
|
|
25
|
+
'payment.error.unavailable': string;
|
|
26
|
+
'payment.error.disabled': string;
|
|
27
|
+
'payment.subscription.grace': string;
|
|
28
|
+
'payment.subscription.hold': string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Copy for developers and operators. **Never** surfaced to an end user. */
|
|
32
|
+
export interface PaymentDeveloperMessageMap {
|
|
33
|
+
'payment.config.invalidCatalog': string;
|
|
34
|
+
'payment.config.missingStoreId': string;
|
|
35
|
+
'payment.config.productionInNonProductionBuild': string;
|
|
36
|
+
'payment.config.storageUnavailable': string;
|
|
37
|
+
'payment.config.environmentBlocked': string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type PaymentMessageMap = PaymentUserMessageMap &
|
|
41
|
+
PaymentDeveloperMessageMap;
|
|
42
|
+
|
|
43
|
+
export type PaymentUserMessageKey = keyof PaymentUserMessageMap;
|
|
44
|
+
export type PaymentDeveloperMessageKey = keyof PaymentDeveloperMessageMap;
|
|
45
|
+
export type PaymentMessageKey = keyof PaymentMessageMap;
|
|
46
|
+
|
|
47
|
+
export const DEFAULT_USER_MESSAGES: PaymentUserMessageMap = {
|
|
48
|
+
'payment.state.pending': 'Waiting for your payment to complete',
|
|
49
|
+
'payment.state.alreadyOwned': 'You already own this',
|
|
50
|
+
'payment.action.restore': 'Restore purchases',
|
|
51
|
+
'payment.restore.succeeded': 'Your purchases have been restored',
|
|
52
|
+
'payment.restore.nothingFound': 'No previous purchases found',
|
|
53
|
+
'payment.error.cancelled': 'Purchase cancelled',
|
|
54
|
+
'payment.error.network':
|
|
55
|
+
"Couldn't reach the store. Check your connection and try again.",
|
|
56
|
+
'payment.error.unavailable': "Purchases aren't available on this device",
|
|
57
|
+
'payment.error.disabled': 'Purchases are currently unavailable',
|
|
58
|
+
'payment.subscription.grace': "There's a problem with your payment method",
|
|
59
|
+
'payment.subscription.hold':
|
|
60
|
+
'Your subscription is paused until payment is updated',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const DEFAULT_DEVELOPER_MESSAGES: PaymentDeveloperMessageMap = {
|
|
64
|
+
'payment.config.invalidCatalog': 'Payment catalog is invalid',
|
|
65
|
+
'payment.config.missingStoreId':
|
|
66
|
+
'Product is missing a store identifier for this platform',
|
|
67
|
+
'payment.config.productionInNonProductionBuild':
|
|
68
|
+
'Production payment configuration in a non-production build',
|
|
69
|
+
'payment.config.storageUnavailable':
|
|
70
|
+
'Local storage unavailable; entitlements will not survive restart',
|
|
71
|
+
'payment.config.environmentBlocked':
|
|
72
|
+
'Purchase blocked: this environment may not create a real transaction',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const DEFAULT_MESSAGES: PaymentMessageMap = {
|
|
76
|
+
...DEFAULT_USER_MESSAGES,
|
|
77
|
+
...DEFAULT_DEVELOPER_MESSAGES,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Every user-actionable key, for an app building its translation resource. */
|
|
81
|
+
export const USER_MESSAGE_KEYS = Object.keys(
|
|
82
|
+
DEFAULT_USER_MESSAGES,
|
|
83
|
+
) as PaymentUserMessageKey[];
|
|
84
|
+
|
|
85
|
+
/** Every developer-facing key, so an app can exclude them from user-visible bundles. */
|
|
86
|
+
export const DEVELOPER_MESSAGE_KEYS = Object.keys(
|
|
87
|
+
DEFAULT_DEVELOPER_MESSAGES,
|
|
88
|
+
) as PaymentDeveloperMessageKey[];
|
|
89
|
+
|
|
90
|
+
/** True for keys safe to show an end user. Lets an app assert that at its boundary. */
|
|
91
|
+
export function isUserMessageKey(
|
|
92
|
+
key: PaymentMessageKey,
|
|
93
|
+
): key is PaymentUserMessageKey {
|
|
94
|
+
return key in DEFAULT_USER_MESSAGES;
|
|
95
|
+
}
|