@onborn/billing 0.1.0-beta.2 → 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,22 @@
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
+
12
+ ## 0.1.0-beta.3
13
+
14
+ - Fixed StoreKit 2 purchases incorrectly using the signed transaction JWS as
15
+ the transaction identifier.
16
+ - Bounded purchase idempotency keys when only a signed purchase token is
17
+ available.
18
+ - Added structured billing request errors with backend messages and codes.
19
+
3
20
  ## 0.1.0-beta.2
4
21
 
5
22
  - Removed `offeringId` from `useOnbornOffering` and
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,6 +1,7 @@
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
+ id?: string;
4
5
  productId?: string;
5
6
  storeProductId?: string;
6
7
  transactionId?: string;
@@ -38,6 +39,8 @@ export type NativeStoresBillingAdapterOptions = {
38
39
  storeProductId: string;
39
40
  }) => Promise<NativeStoresPurchase | void>;
40
41
  restorePurchases?: (input: OnbornRestoreInput) => Promise<NativeStoresRestoreOutput>;
42
+ finalizePurchase?: (result: OnbornPurchaseResult) => Promise<void>;
43
+ finalizeRestore?: (result: OnbornRestoreResult) => Promise<void>;
41
44
  refetchCustomerEntitlements?: (input: {
42
45
  userId?: string;
43
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 {
@@ -143,6 +145,7 @@ function normalizeNativeRestoredPurchase(value) {
143
145
  store,
144
146
  storeProductId,
145
147
  transactionId: readString(record.transactionId) ??
148
+ readString(record.id) ??
146
149
  readString(record.originalTransactionIdentifierIOS),
147
150
  purchaseToken: readString(record.purchaseToken),
148
151
  raw: record.raw ?? value,
@@ -150,7 +153,7 @@ function normalizeNativeRestoredPurchase(value) {
150
153
  }
151
154
  function readTransactionId(purchase) {
152
155
  return (purchase?.transactionId ??
153
- purchase?.purchaseToken ??
156
+ purchase?.id ??
154
157
  purchase?.originalTransactionIdentifierIOS);
155
158
  }
156
159
  function readString(value) {
package/dist/client.d.ts CHANGED
@@ -2,6 +2,11 @@ import { type CustomerEntitlementsResponse, type GetOfferingResponse, type GetPa
2
2
  export type BillingClientOptions = {
3
3
  sourceId?: string;
4
4
  };
5
+ export declare class OnbornBillingRequestError extends Error {
6
+ readonly status: number;
7
+ readonly code?: string | undefined;
8
+ constructor(message: string, status: number, code?: string | undefined);
9
+ }
5
10
  export declare class BillingClient {
6
11
  private readonly options;
7
12
  private readonly config;
package/dist/client.js CHANGED
@@ -2,6 +2,16 @@ import { Onborn, } from "@onborn/analytics";
2
2
  import { resolveOnbornBillingConfig } from "./runtime";
3
3
  import { CustomerEntitlementsResponseSchema, GetOfferingResponseSchema, GetPaywallResponseSchema, PurchaseValidationResponseSchema, } from "@onborn/sdk-contracts";
4
4
  const ONBORN_API_BASE_URL = "https://api.testing.onborn.app";
5
+ export class OnbornBillingRequestError extends Error {
6
+ status;
7
+ code;
8
+ constructor(message, status, code) {
9
+ super(message);
10
+ this.status = status;
11
+ this.code = code;
12
+ this.name = "OnbornBillingRequestError";
13
+ }
14
+ }
5
15
  export class BillingClient {
6
16
  options;
7
17
  config;
@@ -113,7 +123,8 @@ export class BillingClient {
113
123
  body: JSON.stringify(payload),
114
124
  });
115
125
  if (!response.ok) {
116
- throw new Error(`Purchase request failed (${response.status})`);
126
+ const failure = await readBillingFailure(response);
127
+ throw new OnbornBillingRequestError(failure.message ?? `Purchase request failed (${response.status})`, response.status, failure.code);
117
128
  }
118
129
  const parsed = PurchaseValidationResponseSchema.safeParse(await response.json());
119
130
  if (!parsed.success) {
@@ -135,6 +146,22 @@ export class BillingClient {
135
146
  });
136
147
  }
137
148
  }
149
+ async function readBillingFailure(response) {
150
+ try {
151
+ const payload = (await response.json());
152
+ return {
153
+ message: typeof payload.error === "string"
154
+ ? payload.error
155
+ : typeof payload.message === "string"
156
+ ? payload.message
157
+ : undefined,
158
+ code: typeof payload.code === "string" ? payload.code : undefined,
159
+ };
160
+ }
161
+ catch {
162
+ return {};
163
+ }
164
+ }
138
165
  export function createBillingClient(options = {}) {
139
166
  return new BillingClient(options);
140
167
  }
@@ -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
+ }
@@ -44,7 +44,7 @@ export async function validateBillingRestore(input) {
44
44
  }
45
45
  function createPurchaseIdempotencyKey(input) {
46
46
  const stablePart = input.result.transactionId ??
47
- input.result.purchaseToken ??
47
+ fingerprintIdempotencyValue(input.result.purchaseToken) ??
48
48
  input.result.productId ??
49
49
  input.item.product?.storeProductId ??
50
50
  input.item.package.id;
@@ -55,6 +55,18 @@ function createPurchaseIdempotencyKey(input) {
55
55
  stablePart,
56
56
  ].join(":");
57
57
  }
58
+ function fingerprintIdempotencyValue(value) {
59
+ if (!value)
60
+ return undefined;
61
+ let first = 0x811c9dc5;
62
+ let second = 0x9e3779b9;
63
+ for (let index = 0; index < value.length; index += 1) {
64
+ const code = value.charCodeAt(index);
65
+ first = Math.imul(first ^ code, 0x01000193);
66
+ second = Math.imul(second ^ code, 0x85ebca6b);
67
+ }
68
+ return `${(first >>> 0).toString(36)}${(second >>> 0).toString(36)}`;
69
+ }
58
70
  function createRestoreIdempotencyKey(input) {
59
71
  return ["restore", input.offering?.id ?? "unknown", String(Date.now())].join(":");
60
72
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onborn/billing",
3
- "version": "0.1.0-beta.2",
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"