expo-iap 4.2.2 → 4.2.4
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/build/index.d.ts +126 -33
- package/build/index.d.ts.map +1 -1
- package/build/index.js +123 -33
- package/build/index.js.map +1 -1
- package/build/kit-api.d.ts +54 -0
- package/build/kit-api.d.ts.map +1 -0
- package/build/kit-api.js +156 -0
- package/build/kit-api.js.map +1 -0
- package/build/modules/android.d.ts +22 -0
- package/build/modules/android.d.ts.map +1 -1
- package/build/modules/android.js +37 -0
- package/build/modules/android.js.map +1 -1
- package/build/modules/ios.d.ts +69 -1
- package/build/modules/ios.d.ts.map +1 -1
- package/build/modules/ios.js +73 -1
- package/build/modules/ios.js.map +1 -1
- package/build/types.d.ts +241 -75
- package/build/types.d.ts.map +1 -1
- package/build/types.js.map +1 -1
- package/build/useIAP.d.ts.map +1 -1
- package/build/useIAP.js +125 -3
- package/build/useIAP.js.map +1 -1
- package/build/useWebhookEvents.d.ts +26 -0
- package/build/useWebhookEvents.d.ts.map +1 -0
- package/build/useWebhookEvents.js +105 -0
- package/build/useWebhookEvents.js.map +1 -0
- package/build/webhook-client.d.ts +82 -0
- package/build/webhook-client.d.ts.map +1 -0
- package/build/webhook-client.js +176 -0
- package/build/webhook-client.js.map +1 -0
- package/openiap-versions.json +2 -2
- package/package.json +1 -1
- package/src/index.ts +141 -33
- package/src/kit-api.ts +229 -0
- package/src/modules/android.ts +47 -0
- package/src/modules/ios.ts +74 -1
- package/src/types.ts +247 -75
- package/src/useIAP.ts +125 -3
- package/src/useWebhookEvents.ts +155 -0
- package/src/webhook-client.ts +314 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Transport-agnostic webhook client for the openiap kit SSE stream
|
|
2
|
+
// (`GET /v1/webhooks/stream/{apiKey}`). Used by the JavaScript / TS
|
|
3
|
+
// wrappers (react-native-iap, expo-iap) but written without React or
|
|
4
|
+
// React-Native imports so it can also run in plain Node, browser, or
|
|
5
|
+
// any other JS runtime.
|
|
6
|
+
//
|
|
7
|
+
// The wire format is documented in `packages/kit/server/api/v1/webhooks.ts`
|
|
8
|
+
// and matches the GraphQL `WebhookEvent` shape from `webhook.graphql`.
|
|
9
|
+
//
|
|
10
|
+
// Parser logic is split out from the connection so it can be unit-
|
|
11
|
+
// tested without a live server. See `webhook-client.test.ts`.
|
|
12
|
+
export const WEBHOOK_EVENT_TYPES = [
|
|
13
|
+
'SubscriptionStarted',
|
|
14
|
+
'SubscriptionRenewed',
|
|
15
|
+
'SubscriptionExpired',
|
|
16
|
+
'SubscriptionInGracePeriod',
|
|
17
|
+
'SubscriptionInBillingRetry',
|
|
18
|
+
'SubscriptionRecovered',
|
|
19
|
+
'SubscriptionCanceled',
|
|
20
|
+
'SubscriptionUncanceled',
|
|
21
|
+
'SubscriptionRevoked',
|
|
22
|
+
'SubscriptionPriceChange',
|
|
23
|
+
'SubscriptionProductChanged',
|
|
24
|
+
'SubscriptionPaused',
|
|
25
|
+
'SubscriptionResumed',
|
|
26
|
+
'PurchaseRefunded',
|
|
27
|
+
'PurchaseConsumptionRequest',
|
|
28
|
+
'TestNotification',
|
|
29
|
+
];
|
|
30
|
+
const DEFAULT_BASE_URL = 'https://kit.openiap.dev';
|
|
31
|
+
export function connectWebhookStream(options) {
|
|
32
|
+
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
33
|
+
const url = `${trimTrailingSlash(baseUrl)}/v1/webhooks/stream/${encodeURIComponent(options.apiKey)}`;
|
|
34
|
+
const factory = options.eventSourceFactory ?? defaultEventSourceFactory;
|
|
35
|
+
let stream;
|
|
36
|
+
try {
|
|
37
|
+
stream = factory(url, {});
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
options.onError?.({
|
|
41
|
+
code: 'NO_EVENTSOURCE',
|
|
42
|
+
message: error instanceof Error
|
|
43
|
+
? error.message
|
|
44
|
+
: 'EventSource constructor unavailable in this runtime',
|
|
45
|
+
cause: error,
|
|
46
|
+
});
|
|
47
|
+
return { close: () => { } };
|
|
48
|
+
}
|
|
49
|
+
const seenIds = new Set();
|
|
50
|
+
const seenOrder = [];
|
|
51
|
+
const markSeen = (id) => {
|
|
52
|
+
if (seenIds.has(id)) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
seenIds.add(id);
|
|
56
|
+
seenOrder.push(id);
|
|
57
|
+
if (seenOrder.length > 1024) {
|
|
58
|
+
const evicted = seenOrder.shift();
|
|
59
|
+
if (evicted !== undefined) {
|
|
60
|
+
seenIds.delete(evicted);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
};
|
|
65
|
+
const handleData = (raw) => {
|
|
66
|
+
const parsed = parseWebhookEventData(raw);
|
|
67
|
+
if (parsed.kind === 'error') {
|
|
68
|
+
options.onError?.({
|
|
69
|
+
code: 'PARSE_ERROR',
|
|
70
|
+
message: parsed.message,
|
|
71
|
+
});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (parsed.kind === 'skip') {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (markSeen(parsed.event.id)) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
options.onEvent(parsed.event);
|
|
81
|
+
};
|
|
82
|
+
if (typeof stream.addEventListener === 'function') {
|
|
83
|
+
stream.addEventListener('message', (event) => handleData(event.data));
|
|
84
|
+
// WHATWG EventSource dispatches frames with `event: Foo` only to
|
|
85
|
+
// listeners registered for `Foo`, not to `message` / `onmessage`.
|
|
86
|
+
// Kit emits webhook frames as typed SSE events, so subscribe to
|
|
87
|
+
// every known webhook type and keep `message` for older servers or
|
|
88
|
+
// polyfills that collapse typed frames into the generic channel.
|
|
89
|
+
for (const eventType of WEBHOOK_EVENT_TYPES) {
|
|
90
|
+
stream.addEventListener(eventType, (event) => handleData(event.data));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
stream.onmessage = (event) => handleData(event.data);
|
|
95
|
+
}
|
|
96
|
+
stream.onerror = (error) => {
|
|
97
|
+
options.onError?.({
|
|
98
|
+
code: 'TRANSPORT_ERROR',
|
|
99
|
+
message: 'SSE transport error (auto-reconnecting)',
|
|
100
|
+
cause: error,
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
return {
|
|
104
|
+
close: () => {
|
|
105
|
+
try {
|
|
106
|
+
stream.close();
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Closing an already-closed EventSource is a no-op in browsers
|
|
110
|
+
// but throws in some polyfills.
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
export function parseWebhookEventData(raw) {
|
|
116
|
+
if (!raw) {
|
|
117
|
+
return { kind: 'skip', reason: 'heartbeat' };
|
|
118
|
+
}
|
|
119
|
+
let parsed;
|
|
120
|
+
try {
|
|
121
|
+
parsed = JSON.parse(raw);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
return {
|
|
125
|
+
kind: 'error',
|
|
126
|
+
message: error instanceof Error
|
|
127
|
+
? `Failed to parse SSE payload: ${error.message}`
|
|
128
|
+
: 'Failed to parse SSE payload',
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (typeof parsed !== 'object' ||
|
|
132
|
+
parsed === null ||
|
|
133
|
+
!('type' in parsed) ||
|
|
134
|
+
typeof parsed.type !== 'string') {
|
|
135
|
+
// Stream-control messages (the `ready`/`stream-error` envelopes
|
|
136
|
+
// emitted by the kit server) have no `type` and are surfaced as
|
|
137
|
+
// skips so consumers don't see them as events.
|
|
138
|
+
return { kind: 'skip', reason: 'stream-control' };
|
|
139
|
+
}
|
|
140
|
+
const event = parsed;
|
|
141
|
+
if (typeof event.id !== 'string' ||
|
|
142
|
+
typeof event.occurredAt !== 'number' ||
|
|
143
|
+
typeof event.receivedAt !== 'number') {
|
|
144
|
+
return {
|
|
145
|
+
kind: 'error',
|
|
146
|
+
message: `WebhookEvent missing required fields (id/occurredAt/receivedAt)`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
// purchaseToken is required for every event type *except*
|
|
150
|
+
// TestNotification — Apple ASN v2 / Google RTDN test payloads
|
|
151
|
+
// carry no transaction. Hard-rejecting here would surface valid
|
|
152
|
+
// test webhooks as MALFORMED_EVENT and never reach listeners.
|
|
153
|
+
if (event.type !== 'TestNotification' &&
|
|
154
|
+
typeof event.purchaseToken !== 'string') {
|
|
155
|
+
return {
|
|
156
|
+
kind: 'error',
|
|
157
|
+
message: `WebhookEvent missing required field purchaseToken`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return { kind: 'ok', event };
|
|
161
|
+
}
|
|
162
|
+
function trimTrailingSlash(url) {
|
|
163
|
+
return url.endsWith('/') ? url.slice(0, -1) : url;
|
|
164
|
+
}
|
|
165
|
+
function defaultEventSourceFactory(url, _headers) {
|
|
166
|
+
// EventSource is part of the WHATWG spec and available in all
|
|
167
|
+
// browser environments and most JS runtimes (Bun, Node 22+, Deno).
|
|
168
|
+
// RN does not ship it natively — consumers must pass
|
|
169
|
+
// `eventSourceFactory` from `react-native-sse` or similar.
|
|
170
|
+
const ctor = globalThis.EventSource;
|
|
171
|
+
if (!ctor) {
|
|
172
|
+
throw new Error('EventSource is not defined. Pass `eventSourceFactory` for runtimes without a built-in EventSource.');
|
|
173
|
+
}
|
|
174
|
+
return new ctor(url);
|
|
175
|
+
}
|
|
176
|
+
//# sourceMappingURL=webhook-client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook-client.js","sourceRoot":"","sources":["../src/webhook-client.ts"],"names":[],"mappings":"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","sourcesContent":["// Transport-agnostic webhook client for the openiap kit SSE stream\n// (`GET /v1/webhooks/stream/{apiKey}`). Used by the JavaScript / TS\n// wrappers (react-native-iap, expo-iap) but written without React or\n// React-Native imports so it can also run in plain Node, browser, or\n// any other JS runtime.\n//\n// The wire format is documented in `packages/kit/server/api/v1/webhooks.ts`\n// and matches the GraphQL `WebhookEvent` shape from `webhook.graphql`.\n//\n// Parser logic is split out from the connection so it can be unit-\n// tested without a live server. See `webhook-client.test.ts`.\n\nexport type WebhookEventType =\n | 'SubscriptionStarted'\n | 'SubscriptionRenewed'\n | 'SubscriptionExpired'\n | 'SubscriptionInGracePeriod'\n | 'SubscriptionInBillingRetry'\n | 'SubscriptionRecovered'\n | 'SubscriptionCanceled'\n | 'SubscriptionUncanceled'\n | 'SubscriptionRevoked'\n | 'SubscriptionPriceChange'\n | 'SubscriptionProductChanged'\n | 'SubscriptionPaused'\n | 'SubscriptionResumed'\n | 'PurchaseRefunded'\n | 'PurchaseConsumptionRequest'\n | 'TestNotification';\n\nexport const WEBHOOK_EVENT_TYPES = [\n 'SubscriptionStarted',\n 'SubscriptionRenewed',\n 'SubscriptionExpired',\n 'SubscriptionInGracePeriod',\n 'SubscriptionInBillingRetry',\n 'SubscriptionRecovered',\n 'SubscriptionCanceled',\n 'SubscriptionUncanceled',\n 'SubscriptionRevoked',\n 'SubscriptionPriceChange',\n 'SubscriptionProductChanged',\n 'SubscriptionPaused',\n 'SubscriptionResumed',\n 'PurchaseRefunded',\n 'PurchaseConsumptionRequest',\n 'TestNotification',\n] as const satisfies readonly WebhookEventType[];\n\nexport type WebhookEventPayload = {\n id: string;\n type: WebhookEventType;\n source: string;\n platform: 'IOS' | 'Android';\n environment: 'Production' | 'Sandbox' | 'Xcode';\n projectId: string;\n occurredAt: number;\n receivedAt: number;\n // Optional because TestNotification frames carry no transaction;\n // every other event type populates this.\n purchaseToken?: string;\n productId?: string;\n subscriptionState?: string;\n expiresAt?: number;\n renewsAt?: number;\n cancellationReason?: string;\n currency?: string;\n priceAmountMicros?: number;\n rawSignedPayload?: string;\n};\n\nexport type WebhookListenerOptions = {\n /**\n * Project API key. Embedded in the URL path because Apple ASN\n * registration cannot send custom headers; the same path is reused\n * here for symmetry.\n */\n apiKey: string;\n /**\n * Override the kit base URL. Defaults to https://kit.openiap.dev.\n * In tests, point this at a local server.\n */\n baseUrl?: string;\n /** Called on every successfully-parsed webhook event. */\n onEvent: (event: WebhookEventPayload) => void;\n /**\n * Called on transport errors. The connection auto-reconnects\n * unconditionally; this callback exists for telemetry / surfacing\n * to the host UI.\n */\n onError?: (error: WebhookListenerError) => void;\n /**\n * Optional injection of an EventSource constructor. Lets RN /\n * Expo plug in `react-native-event-source` when running on a JS\n * runtime that lacks the global, or vitest plug in a stub.\n */\n eventSourceFactory?: (\n url: string,\n headers: Record<string, string>,\n ) => WebhookEventStream;\n};\n\nexport interface WebhookEventStream {\n close(): void;\n onmessage: ((event: {data: string; lastEventId?: string}) => void) | null;\n onerror: ((error: unknown) => void) | null;\n addEventListener?: (\n type: string,\n listener: (event: {data: string; lastEventId?: string}) => void,\n ) => void;\n}\n\nexport type WebhookListener = {\n /** Tear down the connection and stop receiving events. */\n close(): void;\n};\n\nexport type WebhookListenerError = {\n code:\n | 'TRANSPORT_ERROR'\n | 'PARSE_ERROR'\n | 'MALFORMED_EVENT'\n | 'NO_EVENTSOURCE';\n message: string;\n cause?: unknown;\n};\n\nconst DEFAULT_BASE_URL = 'https://kit.openiap.dev';\n\nexport function connectWebhookStream(\n options: WebhookListenerOptions,\n): WebhookListener {\n const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;\n const url = `${trimTrailingSlash(\n baseUrl,\n )}/v1/webhooks/stream/${encodeURIComponent(options.apiKey)}`;\n\n const factory = options.eventSourceFactory ?? defaultEventSourceFactory;\n let stream: WebhookEventStream;\n try {\n stream = factory(url, {});\n } catch (error) {\n options.onError?.({\n code: 'NO_EVENTSOURCE',\n message:\n error instanceof Error\n ? error.message\n : 'EventSource constructor unavailable in this runtime',\n cause: error,\n });\n return {close: () => {}};\n }\n\n const seenIds = new Set<string>();\n const seenOrder: string[] = [];\n const markSeen = (id: string): boolean => {\n if (seenIds.has(id)) {\n return true;\n }\n seenIds.add(id);\n seenOrder.push(id);\n if (seenOrder.length > 1024) {\n const evicted = seenOrder.shift();\n if (evicted !== undefined) {\n seenIds.delete(evicted);\n }\n }\n return false;\n };\n\n const handleData = (raw: string) => {\n const parsed = parseWebhookEventData(raw);\n if (parsed.kind === 'error') {\n options.onError?.({\n code: 'PARSE_ERROR',\n message: parsed.message,\n });\n return;\n }\n if (parsed.kind === 'skip') {\n return;\n }\n if (markSeen(parsed.event.id)) {\n return;\n }\n options.onEvent(parsed.event);\n };\n\n if (typeof stream.addEventListener === 'function') {\n stream.addEventListener('message', (event) => handleData(event.data));\n // WHATWG EventSource dispatches frames with `event: Foo` only to\n // listeners registered for `Foo`, not to `message` / `onmessage`.\n // Kit emits webhook frames as typed SSE events, so subscribe to\n // every known webhook type and keep `message` for older servers or\n // polyfills that collapse typed frames into the generic channel.\n for (const eventType of WEBHOOK_EVENT_TYPES) {\n stream.addEventListener(eventType, (event) => handleData(event.data));\n }\n } else {\n stream.onmessage = (event) => handleData(event.data);\n }\n\n stream.onerror = (error) => {\n options.onError?.({\n code: 'TRANSPORT_ERROR',\n message: 'SSE transport error (auto-reconnecting)',\n cause: error,\n });\n };\n\n return {\n close: () => {\n try {\n stream.close();\n } catch {\n // Closing an already-closed EventSource is a no-op in browsers\n // but throws in some polyfills.\n }\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n// Pure helpers (exported for testing).\n// ---------------------------------------------------------------------------\n\nexport type ParsedEventResult =\n | {kind: 'ok'; event: WebhookEventPayload}\n | {kind: 'skip'; reason: 'heartbeat' | 'stream-control'}\n | {kind: 'error'; message: string};\n\nexport function parseWebhookEventData(raw: string): ParsedEventResult {\n if (!raw) {\n return {kind: 'skip', reason: 'heartbeat'};\n }\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch (error) {\n return {\n kind: 'error',\n message:\n error instanceof Error\n ? `Failed to parse SSE payload: ${error.message}`\n : 'Failed to parse SSE payload',\n };\n }\n\n if (\n typeof parsed !== 'object' ||\n parsed === null ||\n !('type' in parsed) ||\n typeof (parsed as Record<string, unknown>).type !== 'string'\n ) {\n // Stream-control messages (the `ready`/`stream-error` envelopes\n // emitted by the kit server) have no `type` and are surfaced as\n // skips so consumers don't see them as events.\n return {kind: 'skip', reason: 'stream-control'};\n }\n\n const event = parsed as WebhookEventPayload;\n\n if (\n typeof event.id !== 'string' ||\n typeof event.occurredAt !== 'number' ||\n typeof event.receivedAt !== 'number'\n ) {\n return {\n kind: 'error',\n message: `WebhookEvent missing required fields (id/occurredAt/receivedAt)`,\n };\n }\n // purchaseToken is required for every event type *except*\n // TestNotification — Apple ASN v2 / Google RTDN test payloads\n // carry no transaction. Hard-rejecting here would surface valid\n // test webhooks as MALFORMED_EVENT and never reach listeners.\n if (\n event.type !== 'TestNotification' &&\n typeof event.purchaseToken !== 'string'\n ) {\n return {\n kind: 'error',\n message: `WebhookEvent missing required field purchaseToken`,\n };\n }\n\n return {kind: 'ok', event};\n}\n\nfunction trimTrailingSlash(url: string): string {\n return url.endsWith('/') ? url.slice(0, -1) : url;\n}\n\nfunction defaultEventSourceFactory(\n url: string,\n _headers: Record<string, string>,\n): WebhookEventStream {\n // EventSource is part of the WHATWG spec and available in all\n // browser environments and most JS runtimes (Bun, Node 22+, Deno).\n // RN does not ship it natively — consumers must pass\n // `eventSourceFactory` from `react-native-sse` or similar.\n const ctor = (\n globalThis as {\n EventSource?: new (url: string) => WebhookEventStream;\n }\n ).EventSource;\n if (!ctor) {\n throw new Error(\n 'EventSource is not defined. Pass `eventSourceFactory` for runtimes without a built-in EventSource.',\n );\n }\n return new ctor(url);\n}\n"]}
|
package/openiap-versions.json
CHANGED
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -331,18 +331,58 @@ export const subscriptionBillingIssueListener = (
|
|
|
331
331
|
);
|
|
332
332
|
};
|
|
333
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Initialize the store connection. Must be called before any other IAP API.
|
|
336
|
+
*
|
|
337
|
+
* @param config Optional connection config. Use `enableBillingProgramAndroid` (Android,
|
|
338
|
+
* Play Billing 8.2.0+) to opt into External Payments etc. iOS ignores Android-specific fields.
|
|
339
|
+
* @returns Promise resolving to `true` when the platform billing client is connected.
|
|
340
|
+
* @throws When the platform billing client fails to initialize.
|
|
341
|
+
*
|
|
342
|
+
* @example
|
|
343
|
+
* ```ts
|
|
344
|
+
* await initConnection();
|
|
345
|
+
* await initConnection({ enableBillingProgramAndroid: 'external-offer' });
|
|
346
|
+
* ```
|
|
347
|
+
*
|
|
348
|
+
* @remarks When using `useIAP()`, connection is auto-managed on mount/unmount —
|
|
349
|
+
* pass options to the hook instead of calling this directly.
|
|
350
|
+
*
|
|
351
|
+
* @see {@link https://www.openiap.dev/docs/apis/init-connection}
|
|
352
|
+
*/
|
|
334
353
|
export const initConnection: MutationField<'initConnection'> = async (config) =>
|
|
335
354
|
ExpoIapModule.initConnection(config ?? null);
|
|
336
355
|
|
|
356
|
+
/**
|
|
357
|
+
* Close the store connection and release resources.
|
|
358
|
+
*
|
|
359
|
+
* @see {@link https://www.openiap.dev/docs/apis/end-connection}
|
|
360
|
+
*/
|
|
337
361
|
export const endConnection: MutationField<'endConnection'> = async () =>
|
|
338
362
|
ExpoIapModule.endConnection();
|
|
339
363
|
|
|
340
364
|
/**
|
|
341
|
-
*
|
|
365
|
+
* Retrieve products or subscriptions from the store by SKU.
|
|
366
|
+
*
|
|
367
|
+
* @param request `ProductRequest` — `skus` (string[]) and optional `type`
|
|
368
|
+
* (`'in-app' | 'subs' | 'all'`, defaults to `'in-app'`).
|
|
369
|
+
* @returns Promise resolving to a `FetchProductsResult` union — `Product[]` for `'in-app'`,
|
|
370
|
+
* `ProductSubscription[]` for `'subs'`, or a mixed array for `'all'`.
|
|
371
|
+
* @throws When the store rejects the request (empty `skus`, not connected,
|
|
372
|
+
* network/store error). Unknown SKUs are simply omitted from the result, not thrown.
|
|
342
373
|
*
|
|
343
|
-
* @
|
|
344
|
-
*
|
|
345
|
-
*
|
|
374
|
+
* @example
|
|
375
|
+
* ```ts
|
|
376
|
+
* const products = await fetchProducts({
|
|
377
|
+
* skus: ['com.app.coins_100', 'com.app.premium'],
|
|
378
|
+
* type: 'in-app',
|
|
379
|
+
* });
|
|
380
|
+
* ```
|
|
381
|
+
*
|
|
382
|
+
* @remarks This is a regular promise-based call. Don't confuse with `request*` APIs
|
|
383
|
+
* (`requestPurchase`), which are event-based.
|
|
384
|
+
*
|
|
385
|
+
* @see {@link https://www.openiap.dev/docs/apis/fetch-products}
|
|
346
386
|
*/
|
|
347
387
|
export const fetchProducts: QueryField<'fetchProducts'> = async (request) => {
|
|
348
388
|
ExpoIapConsole.debug('fetchProducts called with:', request);
|
|
@@ -413,6 +453,25 @@ export const fetchProducts: QueryField<'fetchProducts'> = async (request) => {
|
|
|
413
453
|
throw new Error('Unsupported platform');
|
|
414
454
|
};
|
|
415
455
|
|
|
456
|
+
/**
|
|
457
|
+
* List the user's unfinished purchases — non-consumables, active subscriptions, and any
|
|
458
|
+
* pending transactions not yet finished.
|
|
459
|
+
*
|
|
460
|
+
* @param options Optional `PurchaseOptions`. iOS-only flags:
|
|
461
|
+
* `alsoPublishToEventListenerIOS`, `onlyIncludeActiveItemsIOS`.
|
|
462
|
+
* @returns Promise resolving to an array of `Purchase` currently held by the store.
|
|
463
|
+
* @throws When the platform query fails.
|
|
464
|
+
*
|
|
465
|
+
* @example
|
|
466
|
+
* ```ts
|
|
467
|
+
* const purchases = await getAvailablePurchases();
|
|
468
|
+
* for (const p of purchases) {
|
|
469
|
+
* if (await verifyOnServer(p)) await finishTransaction({ purchase: p, isConsumable: false });
|
|
470
|
+
* }
|
|
471
|
+
* ```
|
|
472
|
+
*
|
|
473
|
+
* @see {@link https://www.openiap.dev/docs/apis/get-available-purchases}
|
|
474
|
+
*/
|
|
416
475
|
export const getAvailablePurchases: QueryField<
|
|
417
476
|
'getAvailablePurchases'
|
|
418
477
|
> = async (options) => {
|
|
@@ -467,6 +526,8 @@ export const getAvailablePurchases: QueryField<
|
|
|
467
526
|
* }
|
|
468
527
|
* });
|
|
469
528
|
* ```
|
|
529
|
+
*
|
|
530
|
+
* @see {@link https://www.openiap.dev/docs/apis/get-active-subscriptions}
|
|
470
531
|
*/
|
|
471
532
|
export const getActiveSubscriptions: QueryField<
|
|
472
533
|
'getActiveSubscriptions'
|
|
@@ -491,6 +552,8 @@ export const getActiveSubscriptions: QueryField<
|
|
|
491
552
|
* // Check specific subscriptions
|
|
492
553
|
* const hasPremium = await hasActiveSubscriptions(['premium', 'premium_year']);
|
|
493
554
|
* ```
|
|
555
|
+
*
|
|
556
|
+
* @see {@link https://www.openiap.dev/docs/apis/has-active-subscriptions}
|
|
494
557
|
*/
|
|
495
558
|
export const hasActiveSubscriptions: QueryField<
|
|
496
559
|
'hasActiveSubscriptions'
|
|
@@ -500,6 +563,11 @@ export const hasActiveSubscriptions: QueryField<
|
|
|
500
563
|
));
|
|
501
564
|
};
|
|
502
565
|
|
|
566
|
+
/**
|
|
567
|
+
* Return the user's storefront country code.
|
|
568
|
+
*
|
|
569
|
+
* @see {@link https://www.openiap.dev/docs/apis/get-storefront}
|
|
570
|
+
*/
|
|
503
571
|
export const getStorefront: QueryField<'getStorefront'> = async () => {
|
|
504
572
|
if (Platform.OS !== 'ios' && Platform.OS !== 'android') {
|
|
505
573
|
return '';
|
|
@@ -541,44 +609,30 @@ function normalizeRequestProps(
|
|
|
541
609
|
}
|
|
542
610
|
|
|
543
611
|
/**
|
|
544
|
-
*
|
|
612
|
+
* Initiate a purchase or subscription flow. The result is delivered through
|
|
613
|
+
* `purchaseUpdatedListener` — NOT the return value.
|
|
545
614
|
*
|
|
546
|
-
* @param
|
|
547
|
-
*
|
|
548
|
-
*
|
|
615
|
+
* @param args `RequestPurchaseProps`, discriminated by `type`:
|
|
616
|
+
* - `type: 'in-app'` — pass `request.apple.sku` (iOS) and/or `request.google.skus` (Android).
|
|
617
|
+
* - `type: 'subs'` — same shape, plus `request.google.subscriptionOffers: [{ sku, offerToken }]`.
|
|
618
|
+
* @returns The dispatched purchase payload. **Do not rely on it** for the actual outcome.
|
|
619
|
+
* @throws Synchronous rejection from the store (e.g. `E_NOT_PREPARED`, validation failure).
|
|
549
620
|
*
|
|
550
621
|
* @example
|
|
551
|
-
* ```
|
|
552
|
-
* // Product purchase (recommended: use apple/google)
|
|
622
|
+
* ```ts
|
|
553
623
|
* await requestPurchase({
|
|
554
624
|
* request: {
|
|
555
|
-
* apple: { sku:
|
|
556
|
-
* google: { skus: [
|
|
625
|
+
* apple: { sku: 'com.app.premium' },
|
|
626
|
+
* google: { skus: ['com.app.premium'] },
|
|
557
627
|
* },
|
|
558
|
-
* type: 'in-app'
|
|
628
|
+
* type: 'in-app',
|
|
559
629
|
* });
|
|
630
|
+
* ```
|
|
560
631
|
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
* request: {
|
|
564
|
-
* apple: { sku: subscriptionId },
|
|
565
|
-
* google: {
|
|
566
|
-
* skus: [subscriptionId],
|
|
567
|
-
* subscriptionOffers: [{ sku: subscriptionId, offerToken: 'token' }]
|
|
568
|
-
* }
|
|
569
|
-
* },
|
|
570
|
-
* type: 'subs'
|
|
571
|
-
* });
|
|
632
|
+
* @remarks Event-based. Listen for the result via {@link purchaseUpdatedListener} /
|
|
633
|
+
* {@link purchaseErrorListener}, or use `useIAP({ onPurchaseSuccess, onPurchaseError })`.
|
|
572
634
|
*
|
|
573
|
-
*
|
|
574
|
-
* await requestPurchase({
|
|
575
|
-
* request: {
|
|
576
|
-
* ios: { sku: productId },
|
|
577
|
-
* android: { skus: [productId] }
|
|
578
|
-
* },
|
|
579
|
-
* type: 'in-app'
|
|
580
|
-
* });
|
|
581
|
-
* ```
|
|
635
|
+
* @see {@link https://www.openiap.dev/docs/apis/request-purchase}
|
|
582
636
|
*/
|
|
583
637
|
export const requestPurchase: MutationField<'requestPurchase'> = async (
|
|
584
638
|
args,
|
|
@@ -739,6 +793,29 @@ export const requestPurchase: MutationField<'requestPurchase'> = async (
|
|
|
739
793
|
throw new Error('Platform not supported');
|
|
740
794
|
};
|
|
741
795
|
|
|
796
|
+
/**
|
|
797
|
+
* Complete a purchase transaction. Call after server-side verification to remove it
|
|
798
|
+
* from the queue.
|
|
799
|
+
*
|
|
800
|
+
* @param args.purchase The `Purchase` to finalize.
|
|
801
|
+
* @param args.isConsumable `true` for consumables (consumes the token so the SKU can be
|
|
802
|
+
* re-bought, e.g. coins); `false` (default) for non-consumables and subscriptions.
|
|
803
|
+
* @returns Promise that resolves once the platform finalizes the transaction.
|
|
804
|
+
* @throws When the platform finalize call fails.
|
|
805
|
+
*
|
|
806
|
+
* @example
|
|
807
|
+
* ```ts
|
|
808
|
+
* // Inside purchaseUpdatedListener:
|
|
809
|
+
* if (await verifyOnServer(purchase)) {
|
|
810
|
+
* await finishTransaction({ purchase, isConsumable: false });
|
|
811
|
+
* }
|
|
812
|
+
* ```
|
|
813
|
+
*
|
|
814
|
+
* @remarks **Critical:** Android purchases must be finalized within 3 days or Google
|
|
815
|
+
* auto-refunds. iOS unfinished transactions replay on every app launch.
|
|
816
|
+
*
|
|
817
|
+
* @see {@link https://www.openiap.dev/docs/apis/finish-transaction}
|
|
818
|
+
*/
|
|
742
819
|
export const finishTransaction: MutationField<'finishTransaction'> = async ({
|
|
743
820
|
purchase,
|
|
744
821
|
isConsumable = false,
|
|
@@ -781,6 +858,8 @@ export const finishTransaction: MutationField<'finishTransaction'> = async ({
|
|
|
781
858
|
*
|
|
782
859
|
* This helper triggers the refresh flows but does not return the purchases; consumers should
|
|
783
860
|
* call `getAvailablePurchases` or rely on hook state to inspect the latest items.
|
|
861
|
+
*
|
|
862
|
+
* @see {@link https://www.openiap.dev/docs/apis/restore-purchases}
|
|
784
863
|
*/
|
|
785
864
|
export const restorePurchases: MutationField<'restorePurchases'> = async () => {
|
|
786
865
|
if (Platform.OS === 'ios') {
|
|
@@ -810,6 +889,8 @@ export const restorePurchases: MutationField<'restorePurchases'> = async () => {
|
|
|
810
889
|
* skuAndroid: 'your_subscription_sku',
|
|
811
890
|
* packageNameAndroid: 'com.example.app'
|
|
812
891
|
* });
|
|
892
|
+
*
|
|
893
|
+
* @see {@link https://www.openiap.dev/docs/apis/deep-link-to-subscriptions}
|
|
813
894
|
*/
|
|
814
895
|
export const deepLinkToSubscriptions: MutationField<
|
|
815
896
|
'deepLinkToSubscriptions'
|
|
@@ -836,6 +917,8 @@ export const deepLinkToSubscriptions: MutationField<
|
|
|
836
917
|
* - Android: Use Google Play Developer API with service account credentials
|
|
837
918
|
*
|
|
838
919
|
* @deprecated Use verifyPurchase instead
|
|
920
|
+
*
|
|
921
|
+
* @see {@link https://www.openiap.dev/docs/apis/validate-receipt}
|
|
839
922
|
*/
|
|
840
923
|
export const validateReceipt: MutationField<'validateReceipt'> = async (
|
|
841
924
|
options,
|
|
@@ -881,6 +964,8 @@ export const validateReceipt: MutationField<'validateReceipt'> = async (
|
|
|
881
964
|
*
|
|
882
965
|
* @param options - Receipt validation options containing the SKU
|
|
883
966
|
* @returns Promise resolving to receipt validation result
|
|
967
|
+
*
|
|
968
|
+
* @see {@link https://www.openiap.dev/docs/features/validation#verify-purchase}
|
|
884
969
|
*/
|
|
885
970
|
export const verifyPurchase: MutationField<'verifyPurchase'> = async (
|
|
886
971
|
options,
|
|
@@ -916,6 +1001,8 @@ export const verifyPurchase: MutationField<'verifyPurchase'> = async (
|
|
|
916
1001
|
* }
|
|
917
1002
|
* });
|
|
918
1003
|
* ```
|
|
1004
|
+
*
|
|
1005
|
+
* @see {@link https://www.openiap.dev/docs/features/validation#verify-purchase-with-provider}
|
|
919
1006
|
*/
|
|
920
1007
|
export const verifyPurchaseWithProvider: MutationField<
|
|
921
1008
|
'verifyPurchaseWithProvider'
|
|
@@ -955,6 +1042,27 @@ export const verifyPurchaseWithProvider: MutationField<
|
|
|
955
1042
|
};
|
|
956
1043
|
|
|
957
1044
|
export * from './useIAP';
|
|
1045
|
+
export {useWebhookEvents} from './useWebhookEvents';
|
|
1046
|
+
export type {
|
|
1047
|
+
UseWebhookEventsOptions,
|
|
1048
|
+
UseWebhookEventsResult,
|
|
1049
|
+
} from './useWebhookEvents';
|
|
1050
|
+
export {connectWebhookStream, parseWebhookEventData} from './webhook-client';
|
|
1051
|
+
export type {
|
|
1052
|
+
WebhookEventPayload,
|
|
1053
|
+
WebhookEventStream,
|
|
1054
|
+
WebhookEventType as WebhookEventTypeName,
|
|
1055
|
+
WebhookListener,
|
|
1056
|
+
WebhookListenerError,
|
|
1057
|
+
WebhookListenerOptions,
|
|
1058
|
+
} from './webhook-client';
|
|
1059
|
+
export {kitApi, KitApiError} from './kit-api';
|
|
1060
|
+
export type {
|
|
1061
|
+
KitApiOptions,
|
|
1062
|
+
KitSubscription,
|
|
1063
|
+
EntitlementsResponse,
|
|
1064
|
+
StatusResponse,
|
|
1065
|
+
} from './kit-api';
|
|
958
1066
|
export {
|
|
959
1067
|
ErrorCodeUtils,
|
|
960
1068
|
ErrorCodeMapping,
|