@onborn/billing 0.1.0-beta.3 → 0.1.0-beta.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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-beta.4
4
+
5
+ - Surface native store product-loading failures instead of silently falling
6
+ back to backend catalog metadata without localized store prices.
7
+ - Add the optional `@onborn/billing/expo-iap` adapter with product caching,
8
+ serialized purchases, StoreKit restore synchronization, transaction recovery,
9
+ and automatic purchase/restore transaction finishing after Onborn server
10
+ validation.
11
+
3
12
  ## 0.1.0-beta.3
4
13
 
5
14
  - Fixed StoreKit 2 purchases incorrectly using the signed transaction JWS as
package/README.md CHANGED
@@ -7,11 +7,8 @@ restore validation, and entitlement state without installing the Onborn UI
7
7
  renderer.
8
8
 
9
9
  ```ts
10
- import {
11
- Onborn,
12
- createNativeStoresBillingAdapter,
13
- useOnbornEntitlements,
14
- } from "@onborn/billing";
10
+ import { Onborn, useOnbornEntitlements } from "@onborn/billing";
11
+ import { useExpoIapBillingAdapter } from "@onborn/billing/expo-iap";
15
12
 
16
13
  Onborn.init({
17
14
  apiKey: "onborn_test_...",
@@ -20,19 +17,24 @@ Onborn.init({
20
17
  platform: "ios",
21
18
  });
22
19
 
23
- const billingAdapter = createNativeStoresBillingAdapter({
24
- purchaseProduct: async ({ storeProductId }) => {
25
- // Call expo-iap, react-native-iap, or your native purchase module.
26
- return { productId: storeProductId };
27
- },
28
- });
29
-
30
20
  function PremiumGate() {
31
21
  const { hasEntitlement, loading } = useOnbornEntitlements();
32
22
  return { loading, premium: hasEntitlement("premium") };
33
23
  }
24
+
25
+ function CustomPaywall() {
26
+ const { billingAdapter, connected } = useExpoIapBillingAdapter();
27
+ // Pass billingAdapter to useOnbornOffering or useOnbornPaywall.
28
+ return { billingAdapter, connected };
29
+ }
34
30
  ```
35
31
 
32
+ The optional `@onborn/billing/expo-iap` adapter owns the complete native-store
33
+ lifecycle: connection callbacks, localized product loading and retries,
34
+ serialized purchases, cancellation recovery, StoreKit restore synchronization,
35
+ server validation hand-off, and transaction finishing. Host apps keep only
36
+ their paywall UI and entitlement-driven navigation.
37
+
36
38
  The package owns the Onborn API URL. Configure credentials and user context
37
39
  once through `Onborn.init`; hooks and adapters do not accept an API key.
38
40
 
@@ -45,6 +47,10 @@ Installing `@onborn/billing` also installs its analytics and public-contract
45
47
  dependencies. You do not need `@onborn/rn-sdk` unless you render Onborn-built
46
48
  flows or paywalls.
47
49
 
50
+ Expo apps using the official adapter must also install the optional peer
51
+ dependency `expo-iap` and use an Expo development build. Expo Go does not
52
+ contain this native module.
53
+
48
54
  `@onborn/rn-sdk` re-exports this package for apps that render Onborn flows.
49
55
 
50
56
  ## Documentation
@@ -1,5 +1,5 @@
1
1
  import type { NativeStoreRestoredPurchase } from "@onborn/sdk-contracts";
2
- import type { OnbornBillingAdapter, OnbornLoadProductsInput, OnbornPurchaseInput, OnbornRestoreInput } from "../types";
2
+ import type { OnbornBillingAdapter, OnbornLoadProductsInput, OnbornPurchaseInput, OnbornPurchaseResult, OnbornRestoreInput, OnbornRestoreResult } from "../types";
3
3
  type NativeStoresPurchase = {
4
4
  id?: string;
5
5
  productId?: string;
@@ -39,6 +39,8 @@ export type NativeStoresBillingAdapterOptions = {
39
39
  storeProductId: string;
40
40
  }) => Promise<NativeStoresPurchase | void>;
41
41
  restorePurchases?: (input: OnbornRestoreInput) => Promise<NativeStoresRestoreOutput>;
42
+ finalizePurchase?: (result: OnbornPurchaseResult) => Promise<void>;
43
+ finalizeRestore?: (result: OnbornRestoreResult) => Promise<void>;
42
44
  refetchCustomerEntitlements?: (input: {
43
45
  userId?: string;
44
46
  }) => Promise<unknown>;
@@ -49,6 +49,8 @@ export function createNativeStoresBillingAdapter(options) {
49
49
  raw: normalized.raw,
50
50
  };
51
51
  },
52
+ finalizePurchase: options.finalizePurchase,
53
+ finalizeRestore: options.finalizeRestore,
52
54
  async refetchCustomerEntitlements(input) {
53
55
  const result = await options.refetchCustomerEntitlements?.(input);
54
56
  return {
@@ -0,0 +1,28 @@
1
+ import { type ProductSubscription } from "expo-iap";
2
+ import type { OnbornBillingAdapter } from "./types";
3
+ export type ExpoIapBillingAdapterOptions = {
4
+ /** Additional subscription SKUs that should share the adapter product cache. */
5
+ productIds?: string[];
6
+ purchaseTimeoutMs?: number;
7
+ purchaseRecoveryDelayMs?: number;
8
+ productCacheMs?: number;
9
+ productRetryCount?: number;
10
+ productRetryDelayMs?: number;
11
+ restoreSettleDelayMs?: number;
12
+ };
13
+ export type ExpoIapBillingAdapter = {
14
+ billingAdapter: OnbornBillingAdapter;
15
+ connected: boolean;
16
+ products: ProductSubscription[];
17
+ loadingProducts: boolean;
18
+ productError: Error | null;
19
+ transactionError: Error | null;
20
+ reloadProducts: (productIds?: string[]) => Promise<ProductSubscription[]>;
21
+ };
22
+ /**
23
+ * Official Expo IAP bridge for Onborn billing.
24
+ *
25
+ * Owns store connection state, serialized purchase callbacks, product caching,
26
+ * restore synchronization, transaction recovery, and post-validation finishing.
27
+ */
28
+ export declare function useExpoIapBillingAdapter(options?: ExpoIapBillingAdapterOptions): ExpoIapBillingAdapter;
@@ -0,0 +1,437 @@
1
+ import { fetchProducts, getActiveSubscriptions, getAvailablePurchases, restorePurchases as synchronizeStorePurchases, useIAP, } from "expo-iap";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { Platform } from "react-native";
4
+ import { createNativeStoresBillingAdapter } from "./adapters/nativeStores";
5
+ const DEFAULT_PURCHASE_TIMEOUT_MS = 2 * 60 * 1000;
6
+ const DEFAULT_RECOVERY_DELAY_MS = 750;
7
+ const DEFAULT_PRODUCT_CACHE_MS = 10_000;
8
+ const DEFAULT_PRODUCT_RETRY_DELAY_MS = 500;
9
+ const DEFAULT_RESTORE_SETTLE_DELAY_MS = 300;
10
+ /**
11
+ * Official Expo IAP bridge for Onborn billing.
12
+ *
13
+ * Owns store connection state, serialized purchase callbacks, product caching,
14
+ * restore synchronization, transaction recovery, and post-validation finishing.
15
+ */
16
+ export function useExpoIapBillingAdapter(options = {}) {
17
+ const pendingPurchaseRef = useRef(null);
18
+ const productCacheRef = useRef(null);
19
+ const productRequestRef = useRef(null);
20
+ const knownProductIdsRef = useRef(new Set(normalizeIds(options.productIds)));
21
+ const [products, setProducts] = useState([]);
22
+ const [loadingProducts, setLoadingProducts] = useState(false);
23
+ const [productError, setProductError] = useState(null);
24
+ const [transactionError, setTransactionError] = useState(null);
25
+ useEffect(() => {
26
+ for (const productId of normalizeIds(options.productIds)) {
27
+ knownProductIdsRef.current.add(productId);
28
+ }
29
+ }, [options.productIds]);
30
+ const settlePendingPurchase = useCallback((callback) => {
31
+ const item = pendingPurchaseRef.current;
32
+ if (!item)
33
+ return;
34
+ clearTimeout(item.timeout);
35
+ pendingPurchaseRef.current = null;
36
+ callback(item);
37
+ }, []);
38
+ const { connected, requestPurchase, finishTransaction } = useIAP({
39
+ onPurchaseSuccess: (purchase) => {
40
+ const item = pendingPurchaseRef.current;
41
+ if (!item || item.productId !== purchase.productId)
42
+ return;
43
+ if (item.dispatching) {
44
+ item.bufferedPurchase = purchase;
45
+ return;
46
+ }
47
+ settlePendingPurchase((current) => current.resolve(purchase));
48
+ },
49
+ onPurchaseError: (error) => {
50
+ const item = pendingPurchaseRef.current;
51
+ if (!item || (error.productId && error.productId !== item.productId)) {
52
+ return;
53
+ }
54
+ const normalized = normalizePurchaseError(error);
55
+ if (item.dispatching) {
56
+ item.bufferedError = normalized;
57
+ return;
58
+ }
59
+ settlePendingPurchase((current) => current.reject(normalized));
60
+ },
61
+ });
62
+ useEffect(() => () => {
63
+ settlePendingPurchase((item) => item.reject(new Error("Purchase interrupted.")));
64
+ }, [settlePendingPurchase]);
65
+ const loadProducts = useCallback(async (requestedProductIds, force = false) => {
66
+ if (!connected) {
67
+ throw new Error("Native store is not connected.");
68
+ }
69
+ const requestedIds = normalizeIds(requestedProductIds);
70
+ for (const productId of requestedIds) {
71
+ knownProductIdsRef.current.add(productId);
72
+ }
73
+ if (requestedIds.length === 0)
74
+ return [];
75
+ const cached = productCacheRef.current;
76
+ if (!force &&
77
+ cached &&
78
+ Date.now() - cached.fetchedAt <
79
+ (options.productCacheMs ?? DEFAULT_PRODUCT_CACHE_MS) &&
80
+ requestedIds.every((id) => cached.products.some((product) => product.id === id))) {
81
+ return cached.products.filter((product) => requestedIds.includes(product.id));
82
+ }
83
+ if (!productRequestRef.current) {
84
+ setLoadingProducts(true);
85
+ setProductError(null);
86
+ const allProductIds = Array.from(knownProductIdsRef.current);
87
+ productRequestRef.current = fetchSubscriptionProductsWithRetry(allProductIds, options.productRetryCount ?? 1, options.productRetryDelayMs ?? DEFAULT_PRODUCT_RETRY_DELAY_MS)
88
+ .then((loadedProducts) => {
89
+ productCacheRef.current = {
90
+ products: loadedProducts,
91
+ fetchedAt: Date.now(),
92
+ };
93
+ setProducts(loadedProducts);
94
+ return loadedProducts;
95
+ })
96
+ .catch((error) => {
97
+ const normalized = normalizeError(error, "Native store did not return subscription products.");
98
+ setProductError(normalized);
99
+ throw normalized;
100
+ })
101
+ .finally(() => {
102
+ productRequestRef.current = null;
103
+ setLoadingProducts(false);
104
+ });
105
+ }
106
+ const loadedProducts = await productRequestRef.current;
107
+ const matches = loadedProducts.filter((product) => requestedIds.includes(product.id));
108
+ const missingIds = requestedIds.filter((id) => !matches.some((product) => product.id === id));
109
+ if (missingIds.length > 0) {
110
+ throw new Error(`Native store did not return requested products: ${missingIds.join(", ")}.`);
111
+ }
112
+ return matches;
113
+ }, [
114
+ connected,
115
+ options.productCacheMs,
116
+ options.productRetryCount,
117
+ options.productRetryDelayMs,
118
+ ]);
119
+ const reloadProducts = useCallback(async (productIds = options.productIds ?? []) => {
120
+ const normalizedIds = normalizeIds(productIds);
121
+ return normalizedIds.length > 0
122
+ ? loadProducts(normalizedIds, true)
123
+ : [];
124
+ }, [loadProducts, options.productIds]);
125
+ const recoverPurchase = useCallback(async (productId) => {
126
+ await wait(options.purchaseRecoveryDelayMs ?? DEFAULT_RECOVERY_DELAY_MS);
127
+ try {
128
+ const availablePurchases = await getAvailablePurchases({
129
+ onlyIncludeActiveItemsIOS: true,
130
+ });
131
+ return findPurchase(availablePurchases, productId);
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ }, [options.purchaseRecoveryDelayMs]);
137
+ const billingAdapter = useMemo(() => {
138
+ const nativeAdapter = createNativeStoresBillingAdapter({
139
+ async loadProducts({ storeProductIds }) {
140
+ const nativeProducts = await loadProducts(storeProductIds);
141
+ return nativeProducts.map(normalizeStoreProduct);
142
+ },
143
+ async purchaseProduct({ storeProductId }) {
144
+ if (!connected) {
145
+ throw new Error("Native store is not connected.");
146
+ }
147
+ if (pendingPurchaseRef.current) {
148
+ throw new Error("Another purchase is already in progress.");
149
+ }
150
+ knownProductIdsRef.current.add(storeProductId);
151
+ const purchase = await new Promise((resolve, reject) => {
152
+ const timeout = setTimeout(() => {
153
+ settlePendingPurchase((item) => item.reject(new Error("Purchase timed out.")));
154
+ }, options.purchaseTimeoutMs ?? DEFAULT_PURCHASE_TIMEOUT_MS);
155
+ pendingPurchaseRef.current = {
156
+ productId: storeProductId,
157
+ resolve,
158
+ reject,
159
+ timeout,
160
+ dispatching: true,
161
+ };
162
+ void (async () => {
163
+ try {
164
+ const result = await requestPurchase({
165
+ request: Platform.OS === "android"
166
+ ? { google: { skus: [storeProductId] } }
167
+ : { apple: { sku: storeProductId } },
168
+ type: "subs",
169
+ });
170
+ const item = pendingPurchaseRef.current;
171
+ if (!item || item.productId !== storeProductId)
172
+ return;
173
+ item.dispatching = false;
174
+ const successfulPurchase = findPurchase(result, storeProductId) ?? item.bufferedPurchase;
175
+ if (successfulPurchase) {
176
+ settlePendingPurchase((current) => current.resolve(successfulPurchase));
177
+ return;
178
+ }
179
+ if (item.bufferedError) {
180
+ const recovered = isUserCancelled(item.bufferedError)
181
+ ? await recoverPurchase(storeProductId)
182
+ : null;
183
+ if (recovered && pendingPurchaseRef.current === item) {
184
+ settlePendingPurchase((current) => current.resolve(recovered));
185
+ }
186
+ else if (pendingPurchaseRef.current === item) {
187
+ settlePendingPurchase((current) => current.reject(item.bufferedError));
188
+ }
189
+ }
190
+ }
191
+ catch (error) {
192
+ const item = pendingPurchaseRef.current;
193
+ if (!item || item.productId !== storeProductId)
194
+ return;
195
+ item.dispatching = false;
196
+ if (item.bufferedPurchase) {
197
+ settlePendingPurchase((current) => current.resolve(item.bufferedPurchase));
198
+ return;
199
+ }
200
+ const recovered = isUserCancelled(error)
201
+ ? await recoverPurchase(storeProductId)
202
+ : null;
203
+ if (recovered && pendingPurchaseRef.current === item) {
204
+ settlePendingPurchase((current) => current.resolve(recovered));
205
+ }
206
+ else if (pendingPurchaseRef.current === item) {
207
+ settlePendingPurchase((current) => current.reject(normalizeError(error, "Unable to start purchase.")));
208
+ }
209
+ }
210
+ })();
211
+ });
212
+ return normalizeStorePurchase(purchase);
213
+ },
214
+ async restorePurchases({ products: configuredProducts }) {
215
+ if (!connected) {
216
+ throw new Error("Native store is not connected.");
217
+ }
218
+ const subscriptionIds = normalizeIds([
219
+ ...configuredProducts.map((product) => product.storeProductId),
220
+ ...knownProductIdsRef.current,
221
+ ]);
222
+ await synchronizeStorePurchases();
223
+ await wait(options.restoreSettleDelayMs ?? DEFAULT_RESTORE_SETTLE_DELAY_MS);
224
+ const [availableResult, activeResult] = await Promise.allSettled([
225
+ getAvailablePurchases({ onlyIncludeActiveItemsIOS: true }),
226
+ getActiveSubscriptions(subscriptionIds),
227
+ ]);
228
+ if (availableResult.status === "rejected" &&
229
+ activeResult.status === "rejected") {
230
+ throw availableResult.reason;
231
+ }
232
+ const restoredPurchases = [
233
+ ...(availableResult.status === "fulfilled"
234
+ ? availableResult.value.map(normalizeRestoredPurchase)
235
+ : []),
236
+ ...(activeResult.status === "fulfilled"
237
+ ? activeResult.value
238
+ .filter((subscription) => subscription.isActive)
239
+ .map((subscription) => ({
240
+ store: Platform.OS === "android"
241
+ ? "google_play"
242
+ : "app_store",
243
+ storeProductId: subscription.productId,
244
+ transactionId: subscription.transactionId,
245
+ purchaseToken: subscription.purchaseToken ?? undefined,
246
+ raw: subscription,
247
+ }))
248
+ : []),
249
+ ];
250
+ return {
251
+ purchases: deduplicateRestoredPurchases(restoredPurchases),
252
+ raw: {
253
+ availablePurchases: availableResult.status === "fulfilled"
254
+ ? availableResult.value
255
+ : [],
256
+ activeSubscriptions: activeResult.status === "fulfilled" ? activeResult.value : [],
257
+ },
258
+ };
259
+ },
260
+ });
261
+ return {
262
+ ...nativeAdapter,
263
+ async finalizePurchase(result) {
264
+ const purchase = findPurchase(result.raw, result.productId);
265
+ if (!purchase)
266
+ return;
267
+ setTransactionError(null);
268
+ try {
269
+ await finishTransaction({ purchase, isConsumable: false });
270
+ }
271
+ catch (error) {
272
+ setTransactionError(normalizeError(error, "Unable to finish native transaction."));
273
+ }
274
+ },
275
+ async finalizeRestore(result) {
276
+ const purchases = findPurchases(result.raw);
277
+ if (purchases.length === 0)
278
+ return;
279
+ setTransactionError(null);
280
+ const outcomes = await Promise.allSettled(purchases.map((purchase) => finishTransaction({ purchase, isConsumable: false })));
281
+ const failed = outcomes.find((outcome) => outcome.status === "rejected");
282
+ if (failed) {
283
+ setTransactionError(normalizeError(failed.reason, "Unable to finish restored native transaction."));
284
+ }
285
+ },
286
+ };
287
+ }, [
288
+ connected,
289
+ finishTransaction,
290
+ loadProducts,
291
+ options.purchaseTimeoutMs,
292
+ options.restoreSettleDelayMs,
293
+ recoverPurchase,
294
+ requestPurchase,
295
+ settlePendingPurchase,
296
+ ]);
297
+ return {
298
+ billingAdapter,
299
+ connected,
300
+ products,
301
+ loadingProducts,
302
+ productError,
303
+ transactionError,
304
+ reloadProducts,
305
+ };
306
+ }
307
+ async function fetchSubscriptionProductsWithRetry(productIds, retryCount, retryDelayMs) {
308
+ let lastError;
309
+ for (let attempt = 0; attempt <= Math.max(0, retryCount); attempt += 1) {
310
+ if (attempt > 0)
311
+ await wait(retryDelayMs);
312
+ try {
313
+ const result = await fetchProducts({ skus: productIds, type: "subs" });
314
+ const products = (result ?? []).filter((product) => product.type === "subs");
315
+ if (products.length > 0)
316
+ return products;
317
+ lastError = new Error("Native store returned an empty product catalog.");
318
+ }
319
+ catch (error) {
320
+ lastError = error;
321
+ }
322
+ }
323
+ throw normalizeError(lastError, "Native store did not return subscription products.");
324
+ }
325
+ function normalizeStoreProduct(product) {
326
+ return {
327
+ id: product.id,
328
+ productId: product.id,
329
+ title: product.title,
330
+ description: product.description,
331
+ displayPrice: product.displayPrice,
332
+ currency: product.currency,
333
+ price: product.price ?? undefined,
334
+ subscriptionPeriod: "subscriptionPeriodUnitIOS" in product
335
+ ? product.subscriptionPeriodUnitIOS ?? undefined
336
+ : undefined,
337
+ raw: product,
338
+ };
339
+ }
340
+ function normalizeStorePurchase(purchase) {
341
+ return {
342
+ productId: purchase.productId,
343
+ transactionId: purchase.transactionId ?? purchase.id,
344
+ purchaseToken: purchase.purchaseToken ?? undefined,
345
+ originalTransactionIdentifierIOS: "originalTransactionIdentifierIOS" in purchase
346
+ ? purchase.originalTransactionIdentifierIOS ?? undefined
347
+ : undefined,
348
+ raw: purchase,
349
+ };
350
+ }
351
+ function normalizeRestoredPurchase(purchase) {
352
+ return {
353
+ store: purchase.store === "google"
354
+ ? "google_play"
355
+ : "app_store",
356
+ storeProductId: purchase.productId,
357
+ transactionId: ("originalTransactionIdentifierIOS" in purchase
358
+ ? purchase.originalTransactionIdentifierIOS
359
+ : undefined) ??
360
+ purchase.transactionId ??
361
+ purchase.id,
362
+ purchaseToken: purchase.purchaseToken ?? undefined,
363
+ raw: purchase,
364
+ };
365
+ }
366
+ function deduplicateRestoredPurchases(purchases) {
367
+ return Array.from(new Map(purchases.map((purchase) => [
368
+ `${purchase.storeProductId}:${purchase.transactionId ?? purchase.purchaseToken ?? ""}`,
369
+ purchase,
370
+ ])).values());
371
+ }
372
+ function findPurchase(value, productId) {
373
+ const candidates = Array.isArray(value) ? value : [value];
374
+ for (const candidate of candidates) {
375
+ if (isPurchase(candidate) &&
376
+ (!productId || candidate.productId === productId)) {
377
+ return candidate;
378
+ }
379
+ if (candidate && typeof candidate === "object" && "raw" in candidate) {
380
+ const nested = findPurchase(candidate.raw, productId);
381
+ if (nested)
382
+ return nested;
383
+ }
384
+ }
385
+ return null;
386
+ }
387
+ function findPurchases(value) {
388
+ const found = new Map();
389
+ collectPurchases(value, found, new Set());
390
+ return Array.from(found.values());
391
+ }
392
+ function collectPurchases(value, found, visited) {
393
+ if (!value || typeof value !== "object")
394
+ return;
395
+ if (visited.has(value))
396
+ return;
397
+ visited.add(value);
398
+ if (isPurchase(value)) {
399
+ found.set(`${value.productId}:${value.transactionId ?? value.id}`, value);
400
+ return;
401
+ }
402
+ if (Array.isArray(value)) {
403
+ for (const item of value)
404
+ collectPurchases(item, found, visited);
405
+ return;
406
+ }
407
+ for (const nested of Object.values(value)) {
408
+ collectPurchases(nested, found, visited);
409
+ }
410
+ }
411
+ function isPurchase(value) {
412
+ return Boolean(value &&
413
+ typeof value === "object" &&
414
+ "productId" in value &&
415
+ "transactionDate" in value);
416
+ }
417
+ function normalizePurchaseError(error) {
418
+ const normalized = new Error(error.message);
419
+ normalized.code = error.code;
420
+ normalized.userCancelled = isUserCancelled(error);
421
+ return normalized;
422
+ }
423
+ function isUserCancelled(error) {
424
+ if (!error || typeof error !== "object")
425
+ return false;
426
+ const value = error;
427
+ return value.userCancelled === true || value.code === "user-cancelled";
428
+ }
429
+ function normalizeError(error, fallback) {
430
+ return error instanceof Error ? error : new Error(fallback);
431
+ }
432
+ function normalizeIds(values) {
433
+ return Array.from(new Set((values ?? []).filter((value) => typeof value === "string" && value.trim().length > 0)));
434
+ }
435
+ function wait(ms) {
436
+ return new Promise((resolve) => setTimeout(resolve, ms));
437
+ }
package/dist/types.d.ts CHANGED
@@ -50,7 +50,14 @@ export type OnbornRestoreResult = {
50
50
  export type OnbornBillingAdapter = {
51
51
  loadProducts?: (input: OnbornLoadProductsInput) => Promise<BillingProduct[]>;
52
52
  purchasePackage: (input: OnbornPurchaseInput) => Promise<OnbornPurchaseResult>;
53
+ /**
54
+ * Completes a native transaction after Onborn validates it. Implementations
55
+ * should be idempotent because unfinished store transactions may be replayed.
56
+ */
57
+ finalizePurchase?: (result: OnbornPurchaseResult) => Promise<void>;
53
58
  restorePurchases?: (input: OnbornRestoreInput) => Promise<OnbornRestoreResult>;
59
+ /** Completes restored native transactions after Onborn reconciles them. */
60
+ finalizeRestore?: (result: OnbornRestoreResult) => Promise<void>;
54
61
  refetchCustomerEntitlements?: (input: {
55
62
  userId?: string;
56
63
  }) => Promise<OnbornRestoreResult>;
@@ -94,6 +94,7 @@ export function useOnbornOffering(options = {}) {
94
94
  result: adapterResult,
95
95
  })
96
96
  : adapterResult;
97
+ await finalizePurchase(runtimeOptions.billingAdapter, result);
97
98
  runtimeOptions.onPurchaseCompleted?.(result);
98
99
  notifyEntitlementsChanged(result.entitlements, runtimeOptions.onEntitlementsChanged);
99
100
  return result;
@@ -123,6 +124,7 @@ export function useOnbornOffering(options = {}) {
123
124
  offering: data?.offering,
124
125
  result: adapterResult,
125
126
  });
127
+ await finalizeRestore(runtimeOptions.billingAdapter, result);
126
128
  runtimeOptions.onRestoreCompleted?.(result);
127
129
  notifyEntitlementsChanged(result.entitlements, runtimeOptions.onEntitlementsChanged);
128
130
  return result;
@@ -177,12 +179,7 @@ async function loadLocalizedProducts(billingAdapter, input) {
177
179
  if (!billingAdapter?.loadProducts) {
178
180
  return input.products;
179
181
  }
180
- try {
181
- return await billingAdapter.loadProducts(input);
182
- }
183
- catch {
184
- return input.products;
185
- }
182
+ return billingAdapter.loadProducts(input);
186
183
  }
187
184
  function notifyEntitlementsChanged(entitlements, callback) {
188
185
  if (!entitlements) {
@@ -190,3 +187,13 @@ function notifyEntitlementsChanged(entitlements, callback) {
190
187
  }
191
188
  callback?.(entitlements);
192
189
  }
190
+ async function finalizePurchase(adapter, result) {
191
+ if (!result.success || result.status !== "validated")
192
+ return;
193
+ await adapter.finalizePurchase?.(result);
194
+ }
195
+ async function finalizeRestore(adapter, result) {
196
+ if (!result.success || result.status !== "validated")
197
+ return;
198
+ await adapter.finalizeRestore?.(result);
199
+ }
@@ -150,6 +150,7 @@ export function useOnbornPaywall(options) {
150
150
  result: adapterResult,
151
151
  })
152
152
  : adapterResult;
153
+ await finalizePurchase(runtimeOptions.billingAdapter, result);
153
154
  runtimeOptions.onPurchaseCompleted?.(result);
154
155
  notifyEntitlementsChanged(result.entitlements, runtimeOptions.onEntitlementsChanged);
155
156
  if (result.success && result.status === "validated") {
@@ -231,6 +232,7 @@ export function useOnbornPaywall(options) {
231
232
  offering: data?.offering,
232
233
  result: adapterResult,
233
234
  });
235
+ await finalizeRestore(runtimeOptions.billingAdapter, result);
234
236
  runtimeOptions.onRestoreCompleted?.(result);
235
237
  notifyEntitlementsChanged(result.entitlements, runtimeOptions.onEntitlementsChanged);
236
238
  if (restorePaywall) {
@@ -330,12 +332,7 @@ async function loadLocalizedProducts(billingAdapter, input) {
330
332
  if (!billingAdapter?.loadProducts) {
331
333
  return input.products;
332
334
  }
333
- try {
334
- return await billingAdapter.loadProducts(input);
335
- }
336
- catch {
337
- return input.products;
338
- }
335
+ return billingAdapter.loadProducts(input);
339
336
  }
340
337
  function notifyEntitlementsChanged(entitlements, callback) {
341
338
  if (!entitlements) {
@@ -343,3 +340,13 @@ function notifyEntitlementsChanged(entitlements, callback) {
343
340
  }
344
341
  callback?.(entitlements);
345
342
  }
343
+ async function finalizePurchase(adapter, result) {
344
+ if (!result.success || result.status !== "validated")
345
+ return;
346
+ await adapter.finalizePurchase?.(result);
347
+ }
348
+ async function finalizeRestore(adapter, result) {
349
+ if (!result.success || result.status !== "validated")
350
+ return;
351
+ await adapter.finalizeRestore?.(result);
352
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onborn/billing",
3
- "version": "0.1.0-beta.3",
3
+ "version": "0.1.0-beta.4",
4
4
  "description": "Headless billing, offerings, purchases, restores, and entitlements for Onborn.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -22,6 +22,11 @@
22
22
  "react-native": "./dist/index.js",
23
23
  "types": "./dist/index.d.ts",
24
24
  "default": "./dist/index.js"
25
+ },
26
+ "./expo-iap": {
27
+ "react-native": "./dist/expo-iap.js",
28
+ "types": "./dist/expo-iap.d.ts",
29
+ "default": "./dist/expo-iap.js"
25
30
  }
26
31
  },
27
32
  "files": [
@@ -45,15 +50,22 @@
45
50
  "@onborn/sdk-contracts": "0.1.0-beta.1"
46
51
  },
47
52
  "peerDependencies": {
53
+ "expo-iap": ">=4.5.0",
48
54
  "react": ">=18.0.0",
49
55
  "react-native": ">=0.76.0"
50
56
  },
57
+ "peerDependenciesMeta": {
58
+ "expo-iap": {
59
+ "optional": true
60
+ }
61
+ },
51
62
  "devDependencies": {
52
63
  "@repo/eslint-config": "*",
53
64
  "@repo/typescript-config": "*",
54
65
  "@types/node": "^22.15.3",
55
66
  "@types/react": "~19.2.4",
56
67
  "eslint": "^9.39.1",
68
+ "expo-iap": "4.5.0",
57
69
  "react": "19.2.3",
58
70
  "react-native": "0.86.0",
59
71
  "typescript": "5.9.2"