@onborn/billing 0.1.0-beta.4 → 0.1.0-beta.6

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,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-beta.6
4
+
5
+ - Added `OnbornPurchaseError` with a normalized `code` (`user_cancelled`, `already_owned`, `store_unavailable`, `network_error`, `product_unavailable`, `pending`, `validation_failed`, `not_allowed`, `unknown`), plus `isUserCancelledError` and `toOnbornPurchaseError`. Apps no longer sniff raw store error shapes to tell a cancelled purchase from a real failure.
6
+ - Added price helpers: `formatPrice`, `getPricePerPeriod` (the "only X/week" line), `parseBillingPeriod`, and `resolveBillingPeriod`. They return `undefined` rather than a guess when the period or amount is unknown.
7
+ - The native store adapter now populates `priceAmount`, `billingPeriod`, and `introOffer` on products. iOS intro-offer eligibility is checked per subscription group; offers the customer cannot redeem are omitted.
8
+ - `useOnbornEntitlements({ cache: true })` keeps the last known entitlements per user and replays them on cold start, exposing a `stale` flag until a fresh response lands. The server stays authoritative.
9
+ - `useExpoIapBillingAdapter` exposes `connectionState` (`connecting`/`ready`/`unavailable`) and `retryConnection()`, so apps stop hand-rolling connection timeout state.
10
+ - A failed native store product load no longer discards a valid offering: the paywall renders with synced catalog prices instead of showing a retry button.
11
+
12
+ ## 0.1.0-beta.5
13
+
14
+ - Wait for the native store connection before loading localized products,
15
+ purchasing, or restoring. This prevents a cold-open paywall from showing an
16
+ unavailable state that only clears after a manual retry.
17
+
3
18
  ## 0.1.0-beta.4
4
19
 
5
20
  - Surface native store product-loading failures instead of silently falling
package/README.md CHANGED
@@ -24,7 +24,8 @@ function PremiumGate() {
24
24
 
25
25
  function CustomPaywall() {
26
26
  const { billingAdapter, connected } = useExpoIapBillingAdapter();
27
- // Pass billingAdapter to useOnbornOffering or useOnbornPaywall.
27
+ // Pass billingAdapter immediately. It waits for the native connection.
28
+ // `connected` is only useful for optional loading or disabled UI.
28
29
  return { billingAdapter, connected };
29
30
  }
30
31
  ```
@@ -35,6 +36,10 @@ serialized purchases, cancellation recovery, StoreKit restore synchronization,
35
36
  server validation hand-off, and transaction finishing. Host apps keep only
36
37
  their paywall UI and entitlement-driven navigation.
37
38
 
39
+ Always pass the returned `billingAdapter` directly to `useOnbornOffering` or
40
+ `useOnbornPaywall`. Do not replace it with `undefined` while `connected` is
41
+ false; native operations already wait for the store connection internally.
42
+
38
43
  The package owns the Onborn API URL. Configure credentials and user context
39
44
  once through `Onborn.init`; hooks and adapters do not accept an API key.
40
45
 
@@ -1,4 +1,4 @@
1
- import type { NativeStoreRestoredPurchase } from "@onborn/sdk-contracts";
1
+ import type { BillingPeriod, BillingProductOffer, NativeStoreRestoredPurchase } from "@onborn/sdk-contracts";
2
2
  import type { OnbornBillingAdapter, OnbornLoadProductsInput, OnbornPurchaseInput, OnbornPurchaseResult, OnbornRestoreInput, OnbornRestoreResult } from "../types";
3
3
  type NativeStoresPurchase = {
4
4
  id?: string;
@@ -25,6 +25,10 @@ type NativeStoresProduct = {
25
25
  currencyCode?: string;
26
26
  period?: string;
27
27
  subscriptionPeriod?: string;
28
+ /** Normalized renewal period, when the adapter can determine it. */
29
+ billingPeriod?: BillingPeriod;
30
+ /** Eligibility-checked introductory/promotional offer, when one applies. */
31
+ introOffer?: BillingProductOffer;
28
32
  raw?: unknown;
29
33
  };
30
34
  type NativeStoresRestoreOutput = NativeStoreRestoredPurchase[] | {
@@ -71,12 +71,17 @@ function localizeProducts(products, nativeProducts) {
71
71
  title: readString(nativeProduct.title) ?? product.title,
72
72
  description: readString(nativeProduct.description) ?? product.description,
73
73
  price: readNativePrice(nativeProduct) ?? product.price,
74
+ // The store gives us the amount as a number; surface it as a first-class
75
+ // field so apps never have to dig it back out of `metadata.raw`.
76
+ priceAmount: readPriceAmount(nativeProduct.price) ?? product.priceAmount,
74
77
  currency: readString(nativeProduct.currencyCode) ??
75
78
  readString(nativeProduct.currency) ??
76
79
  product.currency,
77
80
  period: readString(nativeProduct.subscriptionPeriod) ??
78
81
  readString(nativeProduct.period) ??
79
82
  product.period,
83
+ billingPeriod: nativeProduct.billingPeriod ?? product.billingPeriod,
84
+ introOffer: nativeProduct.introOffer ?? product.introOffer,
80
85
  metadata: {
81
86
  ...product.metadata,
82
87
  nativeStoreProduct: nativeProduct.raw ?? nativeProduct,
@@ -167,3 +172,18 @@ function readPriceValue(value) {
167
172
  }
168
173
  return readString(value);
169
174
  }
175
+ /**
176
+ * Stores are inconsistent about the numeric price: StoreKit hands back a
177
+ * number, some Play/JS bridges stringify it. Accept either, reject anything
178
+ * that is not a usable amount.
179
+ */
180
+ function readPriceAmount(value) {
181
+ if (typeof value === "number") {
182
+ return Number.isFinite(value) && value >= 0 ? value : undefined;
183
+ }
184
+ if (typeof value === "string" && value.trim()) {
185
+ const parsed = Number(value.trim());
186
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
187
+ }
188
+ return undefined;
189
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Normalized purchase errors.
3
+ *
4
+ * Store SDKs report failures with their own vocabulary (expo-iap's
5
+ * `ErrorCode`, StoreKit/Play error objects, RevenueCat codes), which pushed
6
+ * every host app into sniffing raw error shapes just to answer the one
7
+ * question every paywall asks: "did the user cancel, or did something break?".
8
+ * Adapters map their store's error onto `OnbornPurchaseError` so apps branch
9
+ * on a stable code instead of a provider's internals.
10
+ */
11
+ export type OnbornPurchaseErrorCode =
12
+ /** The customer dismissed the store sheet. Not an error to report. */
13
+ "user_cancelled"
14
+ /** The customer already owns this product; restore instead of buying. */
15
+ | "already_owned"
16
+ /** Store/billing service unreachable or not initialized on this device. */
17
+ | "store_unavailable"
18
+ /** Transport failure talking to the store. Retrying is reasonable. */
19
+ | "network_error"
20
+ /** The SKU is missing, not approved, or unavailable in this storefront. */
21
+ | "product_unavailable"
22
+ /** Purchase is awaiting external action (Ask to Buy, SCA, slow payment). */
23
+ | "pending"
24
+ /** The store accepted the purchase but ONBORN could not validate it. */
25
+ | "validation_failed"
26
+ /** Purchase is not permitted (parental controls, restricted device). */
27
+ | "not_allowed"
28
+ /** Anything the adapter could not classify. */
29
+ | "unknown";
30
+ export declare class OnbornPurchaseError extends Error {
31
+ readonly code: OnbornPurchaseErrorCode;
32
+ /** The store's own code, kept for logging and support tickets. */
33
+ readonly storeCode?: string;
34
+ readonly productId?: string;
35
+ readonly cause?: unknown;
36
+ constructor(code: OnbornPurchaseErrorCode, message: string, options?: {
37
+ storeCode?: string;
38
+ productId?: string;
39
+ cause?: unknown;
40
+ });
41
+ /** True when the customer dismissed the purchase sheet themselves. */
42
+ get userCancelled(): boolean;
43
+ }
44
+ /**
45
+ * The one check every paywall needs: a cancelled purchase must stay silent,
46
+ * anything else deserves a message. Works on `OnbornPurchaseError` and on raw
47
+ * store errors, so it is safe to call on anything a `catch` block receives.
48
+ */
49
+ export declare function isUserCancelledError(error: unknown): boolean;
50
+ /** Coerce anything thrown by a store into a normalized purchase error. */
51
+ export declare function toOnbornPurchaseError(error: unknown, options?: {
52
+ productId?: string;
53
+ }): OnbornPurchaseError;
package/dist/errors.js ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Normalized purchase errors.
3
+ *
4
+ * Store SDKs report failures with their own vocabulary (expo-iap's
5
+ * `ErrorCode`, StoreKit/Play error objects, RevenueCat codes), which pushed
6
+ * every host app into sniffing raw error shapes just to answer the one
7
+ * question every paywall asks: "did the user cancel, or did something break?".
8
+ * Adapters map their store's error onto `OnbornPurchaseError` so apps branch
9
+ * on a stable code instead of a provider's internals.
10
+ */
11
+ export class OnbornPurchaseError extends Error {
12
+ code;
13
+ /** The store's own code, kept for logging and support tickets. */
14
+ storeCode;
15
+ productId;
16
+ cause;
17
+ constructor(code, message, options) {
18
+ super(message);
19
+ this.name = "OnbornPurchaseError";
20
+ this.code = code;
21
+ this.storeCode = options?.storeCode;
22
+ this.productId = options?.productId;
23
+ this.cause = options?.cause;
24
+ }
25
+ /** True when the customer dismissed the purchase sheet themselves. */
26
+ get userCancelled() {
27
+ return this.code === "user_cancelled";
28
+ }
29
+ }
30
+ /**
31
+ * The one check every paywall needs: a cancelled purchase must stay silent,
32
+ * anything else deserves a message. Works on `OnbornPurchaseError` and on raw
33
+ * store errors, so it is safe to call on anything a `catch` block receives.
34
+ */
35
+ export function isUserCancelledError(error) {
36
+ if (error instanceof OnbornPurchaseError) {
37
+ return error.userCancelled;
38
+ }
39
+ return readStoreErrorCode(error) === "user_cancelled";
40
+ }
41
+ /** Coerce anything thrown by a store into a normalized purchase error. */
42
+ export function toOnbornPurchaseError(error, options) {
43
+ if (error instanceof OnbornPurchaseError) {
44
+ return error;
45
+ }
46
+ const record = error && typeof error === "object"
47
+ ? error
48
+ : {};
49
+ const storeCode = typeof record.code === "string" ? record.code : undefined;
50
+ const message = typeof record.message === "string" && record.message.trim()
51
+ ? record.message
52
+ : "The purchase could not be completed.";
53
+ return new OnbornPurchaseError(readStoreErrorCode(error) ?? "unknown", message, {
54
+ storeCode,
55
+ productId: options?.productId ??
56
+ (typeof record.productId === "string" ? record.productId : undefined),
57
+ cause: error,
58
+ });
59
+ }
60
+ // expo-iap `ErrorCode` values, plus the shapes StoreKit/Play surface directly.
61
+ const STORE_ERROR_CODES = {
62
+ "user-cancelled": "user_cancelled",
63
+ "user-error": "user_cancelled",
64
+ "already-owned": "already_owned",
65
+ "duplicate-purchase": "already_owned",
66
+ "network-error": "network_error",
67
+ "remote-error": "network_error",
68
+ "service-timeout": "network_error",
69
+ "billing-unavailable": "store_unavailable",
70
+ "iap-not-available": "store_unavailable",
71
+ "init-connection": "store_unavailable",
72
+ "not-prepared": "store_unavailable",
73
+ "connection-closed": "store_unavailable",
74
+ "service-disconnected": "store_unavailable",
75
+ "service-error": "store_unavailable",
76
+ "activity-unavailable": "store_unavailable",
77
+ "item-unavailable": "product_unavailable",
78
+ "sku-not-found": "product_unavailable",
79
+ "query-product": "product_unavailable",
80
+ "empty-sku-list": "product_unavailable",
81
+ "sku-offer-mismatch": "product_unavailable",
82
+ pending: "pending",
83
+ "deferred-payment": "pending",
84
+ "transaction-validation-failed": "validation_failed",
85
+ "purchase-verification-failed": "validation_failed",
86
+ "receipt-failed": "validation_failed",
87
+ "feature-not-supported": "not_allowed",
88
+ "developer-error": "not_allowed",
89
+ };
90
+ function readStoreErrorCode(error) {
91
+ if (!error || typeof error !== "object") {
92
+ return undefined;
93
+ }
94
+ const value = error;
95
+ // StoreKit surfaces cancellation as a boolean on some paths.
96
+ if (value.userCancelled === true) {
97
+ return "user_cancelled";
98
+ }
99
+ if (typeof value.code !== "string") {
100
+ return undefined;
101
+ }
102
+ return STORE_ERROR_CODES[value.code.toLowerCase()];
103
+ }
@@ -3,6 +3,8 @@ import type { OnbornBillingAdapter } from "./types";
3
3
  export type ExpoIapBillingAdapterOptions = {
4
4
  /** Additional subscription SKUs that should share the adapter product cache. */
5
5
  productIds?: string[];
6
+ /** Maximum time an operation waits for the native store connection. */
7
+ connectionTimeoutMs?: number;
6
8
  purchaseTimeoutMs?: number;
7
9
  purchaseRecoveryDelayMs?: number;
8
10
  productCacheMs?: number;
@@ -10,9 +12,26 @@ export type ExpoIapBillingAdapterOptions = {
10
12
  productRetryDelayMs?: number;
11
13
  restoreSettleDelayMs?: number;
12
14
  };
15
+ /**
16
+ * Lifecycle of the native store connection.
17
+ *
18
+ * `connected: false` alone cannot tell "still connecting" from "gave up",
19
+ * which is the difference between showing a spinner and showing a retry
20
+ * button — so every app ended up rebuilding this with its own timers.
21
+ */
22
+ export type ExpoIapConnectionState = "connecting" | "ready" | "unavailable";
13
23
  export type ExpoIapBillingAdapter = {
24
+ /**
25
+ * Adapter that can be passed to Onborn hooks immediately. Store
26
+ * operations wait for the native IAP connection internally.
27
+ */
14
28
  billingAdapter: OnbornBillingAdapter;
29
+ /** Native store connection state for optional loading and disabled UI. */
15
30
  connected: boolean;
31
+ /** Same signal as `connected`, but distinguishes "connecting" from "gave up". */
32
+ connectionState: ExpoIapConnectionState;
33
+ /** Re-attempt a store connection after `connectionState` went unavailable. */
34
+ retryConnection: () => Promise<void>;
16
35
  products: ProductSubscription[];
17
36
  loadingProducts: boolean;
18
37
  productError: Error | null;
@@ -24,5 +43,7 @@ export type ExpoIapBillingAdapter = {
24
43
  *
25
44
  * Owns store connection state, serialized purchase callbacks, product caching,
26
45
  * restore synchronization, transaction recovery, and post-validation finishing.
46
+ * Pass `billingAdapter` to Onborn hooks unconditionally; use `connected` only
47
+ * when the host app wants to present store-connection UI.
27
48
  */
28
49
  export declare function useExpoIapBillingAdapter(options?: ExpoIapBillingAdapterOptions): ExpoIapBillingAdapter;
package/dist/expo-iap.js CHANGED
@@ -1,8 +1,10 @@
1
- import { fetchProducts, getActiveSubscriptions, getAvailablePurchases, restorePurchases as synchronizeStorePurchases, useIAP, } from "expo-iap";
1
+ import { fetchProducts, getActiveSubscriptions, getAvailablePurchases, initConnection, isEligibleForIntroOfferIOS, restorePurchases as synchronizeStorePurchases, useIAP, } from "expo-iap";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { Platform } from "react-native";
4
4
  import { createNativeStoresBillingAdapter } from "./adapters/nativeStores";
5
+ import { isUserCancelledError, toOnbornPurchaseError, } from "./errors";
5
6
  const DEFAULT_PURCHASE_TIMEOUT_MS = 2 * 60 * 1000;
7
+ const DEFAULT_CONNECTION_TIMEOUT_MS = 15_000;
6
8
  const DEFAULT_RECOVERY_DELAY_MS = 750;
7
9
  const DEFAULT_PRODUCT_CACHE_MS = 10_000;
8
10
  const DEFAULT_PRODUCT_RETRY_DELAY_MS = 500;
@@ -12,13 +14,19 @@ const DEFAULT_RESTORE_SETTLE_DELAY_MS = 300;
12
14
  *
13
15
  * Owns store connection state, serialized purchase callbacks, product caching,
14
16
  * restore synchronization, transaction recovery, and post-validation finishing.
17
+ * Pass `billingAdapter` to Onborn hooks unconditionally; use `connected` only
18
+ * when the host app wants to present store-connection UI.
15
19
  */
16
20
  export function useExpoIapBillingAdapter(options = {}) {
17
21
  const pendingPurchaseRef = useRef(null);
22
+ const connectedRef = useRef(false);
23
+ const connectionWaitersRef = useRef(new Set());
18
24
  const productCacheRef = useRef(null);
19
25
  const productRequestRef = useRef(null);
20
26
  const knownProductIdsRef = useRef(new Set(normalizeIds(options.productIds)));
21
27
  const [products, setProducts] = useState([]);
28
+ const [connectionState, setConnectionState] = useState("connecting");
29
+ const [connectionAttempt, setConnectionAttempt] = useState(0);
22
30
  const [loadingProducts, setLoadingProducts] = useState(false);
23
31
  const [productError, setProductError] = useState(null);
24
32
  const [transactionError, setTransactionError] = useState(null);
@@ -59,13 +67,75 @@ export function useExpoIapBillingAdapter(options = {}) {
59
67
  settlePendingPurchase((current) => current.reject(normalized));
60
68
  },
61
69
  });
70
+ useEffect(() => {
71
+ connectedRef.current = connected;
72
+ if (!connected)
73
+ return;
74
+ setConnectionState("ready");
75
+ for (const waiter of connectionWaitersRef.current) {
76
+ clearTimeout(waiter.timeout);
77
+ waiter.resolve();
78
+ }
79
+ connectionWaitersRef.current.clear();
80
+ }, [connected]);
81
+ // Give up on the same budget store operations use, so the UI can offer a
82
+ // retry instead of spinning forever.
83
+ useEffect(() => {
84
+ if (connected) {
85
+ return;
86
+ }
87
+ setConnectionState("connecting");
88
+ const timeout = setTimeout(() => setConnectionState("unavailable"), options.connectionTimeoutMs ?? DEFAULT_CONNECTION_TIMEOUT_MS);
89
+ return () => clearTimeout(timeout);
90
+ }, [connected, connectionAttempt, options.connectionTimeoutMs]);
91
+ const retryConnection = useCallback(async () => {
92
+ if (connectedRef.current) {
93
+ return;
94
+ }
95
+ setConnectionState("connecting");
96
+ // Restart the give-up timer even when initConnection resolves instantly;
97
+ // `connected` only flips once expo-iap's own listener fires.
98
+ setConnectionAttempt((attempt) => attempt + 1);
99
+ try {
100
+ await initConnection();
101
+ }
102
+ catch {
103
+ // Leave the state machine to the timer: a failed attempt is only
104
+ // "unavailable" once the budget is actually spent.
105
+ }
106
+ }, []);
107
+ const waitForConnection = useCallback(async () => {
108
+ if (connectedRef.current)
109
+ return;
110
+ await new Promise((resolve, reject) => {
111
+ const waiter = {
112
+ resolve,
113
+ reject,
114
+ timeout: setTimeout(() => {
115
+ connectionWaitersRef.current.delete(waiter);
116
+ reject(new Error("Native store connection timed out."));
117
+ }, options.connectionTimeoutMs ?? DEFAULT_CONNECTION_TIMEOUT_MS),
118
+ };
119
+ connectionWaitersRef.current.add(waiter);
120
+ // The connection may have completed between the first check and
121
+ // registering this waiter.
122
+ if (connectedRef.current) {
123
+ connectionWaitersRef.current.delete(waiter);
124
+ clearTimeout(waiter.timeout);
125
+ resolve();
126
+ }
127
+ });
128
+ }, [options.connectionTimeoutMs]);
62
129
  useEffect(() => () => {
63
130
  settlePendingPurchase((item) => item.reject(new Error("Purchase interrupted.")));
131
+ for (const waiter of connectionWaitersRef.current) {
132
+ clearTimeout(waiter.timeout);
133
+ waiter.reject(new Error("Native store connection was interrupted."));
134
+ }
135
+ connectionWaitersRef.current.clear();
64
136
  }, [settlePendingPurchase]);
65
137
  const loadProducts = useCallback(async (requestedProductIds, force = false) => {
66
- if (!connected) {
67
- throw new Error("Native store is not connected.");
68
- }
138
+ await waitForConnection();
69
139
  const requestedIds = normalizeIds(requestedProductIds);
70
140
  for (const productId of requestedIds) {
71
141
  knownProductIdsRef.current.add(productId);
@@ -111,10 +181,10 @@ export function useExpoIapBillingAdapter(options = {}) {
111
181
  }
112
182
  return matches;
113
183
  }, [
114
- connected,
115
184
  options.productCacheMs,
116
185
  options.productRetryCount,
117
186
  options.productRetryDelayMs,
187
+ waitForConnection,
118
188
  ]);
119
189
  const reloadProducts = useCallback(async (productIds = options.productIds ?? []) => {
120
190
  const normalizedIds = normalizeIds(productIds);
@@ -138,12 +208,11 @@ export function useExpoIapBillingAdapter(options = {}) {
138
208
  const nativeAdapter = createNativeStoresBillingAdapter({
139
209
  async loadProducts({ storeProductIds }) {
140
210
  const nativeProducts = await loadProducts(storeProductIds);
141
- return nativeProducts.map(normalizeStoreProduct);
211
+ const eligibility = await resolveIntroOfferEligibility(nativeProducts);
212
+ return nativeProducts.map((product) => normalizeStoreProduct(product, eligibility));
142
213
  },
143
214
  async purchaseProduct({ storeProductId }) {
144
- if (!connected) {
145
- throw new Error("Native store is not connected.");
146
- }
215
+ await waitForConnection();
147
216
  if (pendingPurchaseRef.current) {
148
217
  throw new Error("Another purchase is already in progress.");
149
218
  }
@@ -177,7 +246,7 @@ export function useExpoIapBillingAdapter(options = {}) {
177
246
  return;
178
247
  }
179
248
  if (item.bufferedError) {
180
- const recovered = isUserCancelled(item.bufferedError)
249
+ const recovered = isUserCancelledError(item.bufferedError)
181
250
  ? await recoverPurchase(storeProductId)
182
251
  : null;
183
252
  if (recovered && pendingPurchaseRef.current === item) {
@@ -197,7 +266,7 @@ export function useExpoIapBillingAdapter(options = {}) {
197
266
  settlePendingPurchase((current) => current.resolve(item.bufferedPurchase));
198
267
  return;
199
268
  }
200
- const recovered = isUserCancelled(error)
269
+ const recovered = isUserCancelledError(error)
201
270
  ? await recoverPurchase(storeProductId)
202
271
  : null;
203
272
  if (recovered && pendingPurchaseRef.current === item) {
@@ -212,9 +281,7 @@ export function useExpoIapBillingAdapter(options = {}) {
212
281
  return normalizeStorePurchase(purchase);
213
282
  },
214
283
  async restorePurchases({ products: configuredProducts }) {
215
- if (!connected) {
216
- throw new Error("Native store is not connected.");
217
- }
284
+ await waitForConnection();
218
285
  const subscriptionIds = normalizeIds([
219
286
  ...configuredProducts.map((product) => product.storeProductId),
220
287
  ...knownProductIdsRef.current,
@@ -285,7 +352,6 @@ export function useExpoIapBillingAdapter(options = {}) {
285
352
  },
286
353
  };
287
354
  }, [
288
- connected,
289
355
  finishTransaction,
290
356
  loadProducts,
291
357
  options.purchaseTimeoutMs,
@@ -293,10 +359,13 @@ export function useExpoIapBillingAdapter(options = {}) {
293
359
  recoverPurchase,
294
360
  requestPurchase,
295
361
  settlePendingPurchase,
362
+ waitForConnection,
296
363
  ]);
297
364
  return {
298
365
  billingAdapter,
299
366
  connected,
367
+ connectionState,
368
+ retryConnection,
300
369
  products,
301
370
  loadingProducts,
302
371
  productError,
@@ -322,7 +391,7 @@ async function fetchSubscriptionProductsWithRetry(productIds, retryCount, retryD
322
391
  }
323
392
  throw normalizeError(lastError, "Native store did not return subscription products.");
324
393
  }
325
- function normalizeStoreProduct(product) {
394
+ function normalizeStoreProduct(product, offerEligibility) {
326
395
  return {
327
396
  id: product.id,
328
397
  productId: product.id,
@@ -334,9 +403,150 @@ function normalizeStoreProduct(product) {
334
403
  subscriptionPeriod: "subscriptionPeriodUnitIOS" in product
335
404
  ? product.subscriptionPeriodUnitIOS ?? undefined
336
405
  : undefined,
406
+ billingPeriod: readProductBillingPeriod(product),
407
+ introOffer: readProductIntroOffer(product, offerEligibility),
337
408
  raw: product,
338
409
  };
339
410
  }
411
+ function readSubscriptionGroupId(product) {
412
+ const groupId = product
413
+ .subscriptionGroupIdIOS;
414
+ return typeof groupId === "string" && groupId.trim() ? groupId : undefined;
415
+ }
416
+ /**
417
+ * Ask the App Store which subscription groups this customer may still claim an
418
+ * intro offer in. Answers are resolved once per group (not per product, since
419
+ * a group usually holds several SKUs) and only on iOS: Play already filters
420
+ * offers by eligibility, so its advertised offers are usable as-is.
421
+ *
422
+ * A failed check falls back to "eligible" — the store itself is the final
423
+ * authority at purchase time, so a hidden offer costs a conversion while a
424
+ * shown one merely reverts to the standard price.
425
+ */
426
+ async function resolveIntroOfferEligibility(products) {
427
+ if (Platform.OS !== "ios") {
428
+ return () => true;
429
+ }
430
+ const groupIds = new Set(products
431
+ .filter((product) => product
432
+ .subscriptionOffers?.length)
433
+ .map(readSubscriptionGroupId)
434
+ .filter((groupId) => Boolean(groupId)));
435
+ if (groupIds.size === 0) {
436
+ return () => true;
437
+ }
438
+ const results = await Promise.all(Array.from(groupIds).map(async (groupId) => {
439
+ try {
440
+ return [groupId, await isEligibleForIntroOfferIOS(groupId)];
441
+ }
442
+ catch {
443
+ return [groupId, true];
444
+ }
445
+ }));
446
+ const eligibilityByGroup = new Map(results);
447
+ return (product) => {
448
+ const groupId = readSubscriptionGroupId(product);
449
+ if (!groupId) {
450
+ return true;
451
+ }
452
+ return eligibilityByGroup.get(groupId) ?? true;
453
+ };
454
+ }
455
+ function toBillingPeriodUnit(unit) {
456
+ switch (unit?.toLowerCase()) {
457
+ case "day":
458
+ return "day";
459
+ case "week":
460
+ return "week";
461
+ case "month":
462
+ return "month";
463
+ case "year":
464
+ return "year";
465
+ default:
466
+ // "unknown"/"empty" are real store values; treat them as no period
467
+ // rather than guessing a unit and corrupting downstream price math.
468
+ return undefined;
469
+ }
470
+ }
471
+ function readProductBillingPeriod(product) {
472
+ const record = product;
473
+ const unit = toBillingPeriodUnit(record.subscriptionPeriodUnitIOS);
474
+ if (unit) {
475
+ const count = Number(record.subscriptionPeriodNumberIOS ?? 1);
476
+ return { unit, count: Number.isFinite(count) && count > 0 ? count : 1 };
477
+ }
478
+ // Android reports the renewal period on the base plan's regular offer.
479
+ for (const offer of record.subscriptionOffers ?? []) {
480
+ if (offer.type === "introductory" || offer.type === "promotional") {
481
+ continue;
482
+ }
483
+ const period = toBillingPeriod(offer.period);
484
+ if (period) {
485
+ return period;
486
+ }
487
+ }
488
+ return undefined;
489
+ }
490
+ function toBillingPeriod(period) {
491
+ const unit = toBillingPeriodUnit(period?.unit);
492
+ if (!unit) {
493
+ return undefined;
494
+ }
495
+ const count = period?.value ?? 1;
496
+ return { unit, count: Number.isFinite(count) && count > 0 ? count : 1 };
497
+ }
498
+ function toOfferPaymentMode(mode) {
499
+ switch (mode?.toLowerCase()) {
500
+ case "free-trial":
501
+ return "free_trial";
502
+ case "pay-as-you-go":
503
+ return "pay_as_you_go";
504
+ case "pay-up-front":
505
+ return "pay_up_front";
506
+ default:
507
+ return "unknown";
508
+ }
509
+ }
510
+ /**
511
+ * Resolve the introductory offer a customer can actually use.
512
+ *
513
+ * Both stores advertise offers on the product, but "advertised" is not
514
+ * "available to this customer": on iOS the App Store only honours an intro
515
+ * offer once per subscription group, so eligibility must be checked against
516
+ * that group. Play only returns offers the customer qualifies for, so their
517
+ * presence is the answer. An offer the customer cannot redeem is omitted
518
+ * entirely — showing its price would be a lie on the paywall.
519
+ */
520
+ function readProductIntroOffer(product, offerEligibility) {
521
+ const offers = product
522
+ .subscriptionOffers ?? [];
523
+ const offer = offers.find((item) => item.type === "introductory") ??
524
+ offers.find((item) => item.type === "promotional");
525
+ if (!offer) {
526
+ return undefined;
527
+ }
528
+ const eligible = offerEligibility ? offerEligibility(product) : true;
529
+ if (!eligible) {
530
+ return undefined;
531
+ }
532
+ return {
533
+ ...(offer.id ? { id: offer.id } : {}),
534
+ type: offer.type === "promotional" ? "promotional" : "introductory",
535
+ ...(offer.displayPrice ? { price: offer.displayPrice } : {}),
536
+ ...(typeof offer.price === "number" && Number.isFinite(offer.price)
537
+ ? { priceAmount: offer.price }
538
+ : {}),
539
+ ...(offer.currency ? { currency: offer.currency } : {}),
540
+ paymentMode: toOfferPaymentMode(offer.paymentMode),
541
+ ...(toBillingPeriod(offer.period)
542
+ ? { period: toBillingPeriod(offer.period) }
543
+ : {}),
544
+ ...(typeof offer.periodCount === "number" && offer.periodCount > 0
545
+ ? { periodCount: offer.periodCount }
546
+ : {}),
547
+ eligible: true,
548
+ };
549
+ }
340
550
  function normalizeStorePurchase(purchase) {
341
551
  return {
342
552
  productId: purchase.productId,
@@ -415,16 +625,7 @@ function isPurchase(value) {
415
625
  "transactionDate" in value);
416
626
  }
417
627
  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";
628
+ return toOnbornPurchaseError(error, { productId: error.productId });
428
629
  }
429
630
  function normalizeError(error, fallback) {
430
631
  return error instanceof Error ? error : new Error(fallback);
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from "./adapters";
2
2
  export * from "./client";
3
+ export * from "./errors";
3
4
  export * from "./runtime";
4
5
  export * from "./types";
5
6
  export * from "./useOnbornEntitlements";
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from "./adapters";
2
2
  export * from "./client";
3
+ export * from "./errors";
3
4
  export * from "./runtime";
4
5
  export * from "./types";
5
6
  export * from "./useOnbornEntitlements";
@@ -1,10 +1,24 @@
1
1
  import { type CustomerEntitlementsResponse } from "@onborn/sdk-contracts";
2
2
  export type UseOnbornEntitlementsOptions = {
3
3
  autoLoad?: boolean;
4
+ /**
5
+ * Keep the last known entitlements in `analyticsStorage` and replay them on
6
+ * the next cold start.
7
+ *
8
+ * Without this, `data` is null until the network answers, so a paying
9
+ * customer sees the free experience for the first moments of every launch —
10
+ * and for the whole session if they are offline. Cached entitlements are
11
+ * marked `stale` until a fresh response arrives; they are a UX bridge, never
12
+ * proof of entitlement (the server stays authoritative for anything that
13
+ * actually matters).
14
+ */
15
+ cache?: boolean;
4
16
  };
5
17
  export type UseOnbornEntitlementsState = {
6
18
  data: CustomerEntitlementsResponse | null;
7
19
  loading: boolean;
20
+ /** True while `data` comes from the cache and no fresh response has landed. */
21
+ stale: boolean;
8
22
  error: string | null;
9
23
  reload: () => Promise<CustomerEntitlementsResponse>;
10
24
  hasEntitlement: (keyOrId: string) => boolean;
@@ -1,11 +1,14 @@
1
- import { useCallback, useEffect, useMemo, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { createBillingClient } from "./client";
3
3
  import { useOnbornBillingConfig } from "./runtime";
4
+ const ENTITLEMENTS_CACHE_PREFIX = "onborn:entitlements";
4
5
  export function useOnbornEntitlements(options = {}) {
5
6
  const runtimeOptions = useOnbornBillingConfig(options);
6
7
  const [data, setData] = useState(null);
7
8
  const [loading, setLoading] = useState(options.autoLoad !== false);
9
+ const [stale, setStale] = useState(false);
8
10
  const [error, setError] = useState(null);
11
+ const freshRef = useRef(false);
9
12
  const client = useMemo(() => createBillingClient({
10
13
  sourceId: "entitlements",
11
14
  }), [
@@ -21,12 +24,26 @@ export function useOnbornEntitlements(options = {}) {
21
24
  runtimeOptions.sdkVersion,
22
25
  runtimeOptions.userId,
23
26
  ]);
27
+ // Scope the cache to the user: a device that switches accounts (or upgrades
28
+ // from anonymous to signed-in) must never replay the previous user's
29
+ // entitlements.
30
+ const cacheKey = options.cache
31
+ ? `${ENTITLEMENTS_CACHE_PREFIX}:${runtimeOptions.userId ?? "anonymous"}`
32
+ : null;
33
+ const storage = options.cache ? runtimeOptions.analyticsStorage : undefined;
24
34
  const reload = useCallback(async () => {
25
35
  setLoading(true);
26
36
  setError(null);
27
37
  try {
28
38
  const response = await client.loadCustomerEntitlements();
39
+ freshRef.current = true;
29
40
  setData(response);
41
+ setStale(false);
42
+ if (storage && cacheKey) {
43
+ void storage
44
+ .setItem(cacheKey, JSON.stringify(response))
45
+ .catch(() => undefined);
46
+ }
30
47
  return response;
31
48
  }
32
49
  catch (loadError) {
@@ -37,18 +54,44 @@ export function useOnbornEntitlements(options = {}) {
37
54
  finally {
38
55
  setLoading(false);
39
56
  }
40
- }, [client]);
57
+ }, [cacheKey, client, storage]);
58
+ useEffect(() => {
59
+ if (!storage || !cacheKey) {
60
+ return;
61
+ }
62
+ let cancelled = false;
63
+ void (async () => {
64
+ try {
65
+ const cached = await storage.getItem(cacheKey);
66
+ // A fresh response may have landed while storage was reading; it always
67
+ // wins over the cache.
68
+ if (cancelled || !cached || freshRef.current) {
69
+ return;
70
+ }
71
+ setData(JSON.parse(cached));
72
+ setStale(true);
73
+ }
74
+ catch {
75
+ // A corrupt or unreadable cache is not worth surfacing: the network
76
+ // response is on its way regardless.
77
+ }
78
+ })();
79
+ return () => {
80
+ cancelled = true;
81
+ };
82
+ }, [cacheKey, storage]);
41
83
  useEffect(() => {
42
84
  if (options.autoLoad === false) {
43
85
  return;
44
86
  }
45
- void reload();
87
+ void reload().catch(() => undefined);
46
88
  }, [options.autoLoad, reload]);
47
89
  const hasEntitlement = useCallback((keyOrId) => data?.entitlements.some((item) => item.active &&
48
90
  (item.key === keyOrId || item.entitlementId === keyOrId)) ?? false, [data?.entitlements]);
49
91
  return {
50
92
  data,
51
93
  loading,
94
+ stale,
52
95
  error,
53
96
  reload,
54
97
  hasEntitlement,
@@ -38,10 +38,21 @@ export function useOnbornOffering(options = {}) {
38
38
  setError(null);
39
39
  try {
40
40
  const response = await client.loadOffering();
41
+ // Store localization is an enhancement, not a precondition. The native
42
+ // store connection can be cold or briefly unavailable (app resumed,
43
+ // StoreKit reconnecting), and treating that as fatal blanked an offering
44
+ // the backend had already returned — the paywall showed a retry button
45
+ // even though it had everything it needed to render. Fall back to the
46
+ // synced catalog products instead; `purchasePackage` still goes through
47
+ // the adapter and surfaces a real error if the store is truly
48
+ // unreachable.
41
49
  const products = await loadLocalizedProducts(runtimeOptions.billingAdapter, {
42
50
  offering: response.offering,
43
51
  products: response.products,
44
52
  userId: runtimeOptions.userId,
53
+ }).catch((productsError) => {
54
+ console.warn("[onborn] Native store products unavailable; showing catalog prices.", productsError);
55
+ return response.products;
45
56
  });
46
57
  setData({ ...response, products });
47
58
  setSelectedPackageId((current) => {
package/dist/utils.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { BillingOffering, BillingPackage, BillingPlatform, BillingProduct } from "@onborn/sdk-contracts";
1
+ import type { BillingOffering, BillingPackage, BillingPeriod, BillingPeriodUnit, BillingPlatform, BillingProduct } from "@onborn/sdk-contracts";
2
2
  import type { OnbornPackageWithProduct } from "./types";
3
3
  export declare function getPackagesWithProducts(offering: BillingOffering | undefined, products: BillingProduct[] | undefined, platform?: BillingPlatform): OnbornPackageWithProduct[];
4
4
  export declare function resolveDefaultPackageId(offering: BillingOffering | undefined): string | undefined;
@@ -6,3 +6,38 @@ export declare function findPackageWithProduct(packages: OnbornPackageWithProduc
6
6
  export declare function formatPackagePrice(product: BillingProduct | undefined, fallback?: string): string;
7
7
  export declare function getStoreProductId(billingPackage: BillingPackage, product: BillingProduct | undefined): string;
8
8
  export declare function formatBillingPeriod(period: string | undefined): string;
9
+ /**
10
+ * @deprecated Use `BillingPeriod` from `@onborn/sdk-contracts`.
11
+ * Kept as an alias so existing imports keep compiling.
12
+ */
13
+ export type ParsedBillingPeriod = BillingPeriod;
14
+ /**
15
+ * Parse a store billing period into machine-readable form.
16
+ *
17
+ * Stores disagree on the wire format: Apple sends ISO-8601 durations (`P1Y`),
18
+ * Play sends the same or a named constant, and catalog sync may pass through
19
+ * whatever the store gave it. Anything unrecognized returns `undefined` rather
20
+ * than a guess — a wrong period silently corrupts price math.
21
+ */
22
+ export declare function parseBillingPeriod(period: string | undefined): BillingPeriod | undefined;
23
+ /** Format an amount using the store's currency, falling back to a plain number. */
24
+ export declare function formatPrice(amount: number, currency: string | undefined, locale?: string): string;
25
+ /**
26
+ * Break a subscription price down to a shorter unit — the "only 9,99 zł/week"
27
+ * line every annual plan shows.
28
+ *
29
+ * Returns `undefined` (never a guess) when the product has no numeric price or
30
+ * an unparseable period, so a missing value is visibly missing instead of
31
+ * quietly wrong.
32
+ */
33
+ export declare function getPricePerPeriod(product: BillingProduct | undefined, unit: BillingPeriodUnit, options?: {
34
+ locale?: string;
35
+ }): {
36
+ amount: number;
37
+ formatted: string;
38
+ } | undefined;
39
+ /**
40
+ * The product's renewal period, preferring the adapter-normalized
41
+ * `billingPeriod` and falling back to parsing the raw store string.
42
+ */
43
+ export declare function resolveBillingPeriod(product: BillingProduct | undefined): BillingPeriod | undefined;
package/dist/utils.js CHANGED
@@ -56,3 +56,115 @@ export function formatBillingPeriod(period) {
56
56
  return period ?? "";
57
57
  }
58
58
  }
59
+ const PERIOD_UNIT_DAYS = {
60
+ day: 1,
61
+ week: 7,
62
+ month: 30.4375,
63
+ year: 365.25,
64
+ };
65
+ const NAMED_PERIODS = {
66
+ day: { unit: "day", count: 1 },
67
+ daily: { unit: "day", count: 1 },
68
+ one_day: { unit: "day", count: 1 },
69
+ week: { unit: "week", count: 1 },
70
+ weekly: { unit: "week", count: 1 },
71
+ one_week: { unit: "week", count: 1 },
72
+ month: { unit: "month", count: 1 },
73
+ monthly: { unit: "month", count: 1 },
74
+ one_month: { unit: "month", count: 1 },
75
+ two_months: { unit: "month", count: 2 },
76
+ three_months: { unit: "month", count: 3 },
77
+ six_months: { unit: "month", count: 6 },
78
+ year: { unit: "year", count: 1 },
79
+ yearly: { unit: "year", count: 1 },
80
+ annual: { unit: "year", count: 1 },
81
+ one_year: { unit: "year", count: 1 },
82
+ };
83
+ /**
84
+ * Parse a store billing period into machine-readable form.
85
+ *
86
+ * Stores disagree on the wire format: Apple sends ISO-8601 durations (`P1Y`),
87
+ * Play sends the same or a named constant, and catalog sync may pass through
88
+ * whatever the store gave it. Anything unrecognized returns `undefined` rather
89
+ * than a guess — a wrong period silently corrupts price math.
90
+ */
91
+ export function parseBillingPeriod(period) {
92
+ const normalized = period?.trim().toLowerCase();
93
+ if (!normalized) {
94
+ return undefined;
95
+ }
96
+ const named = NAMED_PERIODS[normalized];
97
+ if (named) {
98
+ return named;
99
+ }
100
+ // ISO-8601 duration: P1Y, P6M, P2W, P30D.
101
+ const iso = normalized.match(/^p(\d+)([dwmy])$/);
102
+ if (!iso) {
103
+ return undefined;
104
+ }
105
+ const count = Number(iso[1]);
106
+ if (!Number.isFinite(count) || count <= 0) {
107
+ return undefined;
108
+ }
109
+ const unit = { d: "day", w: "week", m: "month", y: "year" };
110
+ const key = iso[2];
111
+ return { unit: unit[key], count };
112
+ }
113
+ /** Format an amount using the store's currency, falling back to a plain number. */
114
+ export function formatPrice(amount, currency, locale) {
115
+ if (!Number.isFinite(amount)) {
116
+ return "";
117
+ }
118
+ if (currency) {
119
+ try {
120
+ return new Intl.NumberFormat(locale, {
121
+ style: "currency",
122
+ currency,
123
+ maximumFractionDigits: 2,
124
+ }).format(amount);
125
+ }
126
+ catch {
127
+ // Unknown currency code or an environment without full ICU data.
128
+ }
129
+ }
130
+ return amount.toFixed(2);
131
+ }
132
+ /**
133
+ * Break a subscription price down to a shorter unit — the "only 9,99 zł/week"
134
+ * line every annual plan shows.
135
+ *
136
+ * Returns `undefined` (never a guess) when the product has no numeric price or
137
+ * an unparseable period, so a missing value is visibly missing instead of
138
+ * quietly wrong.
139
+ */
140
+ export function getPricePerPeriod(product, unit, options) {
141
+ const amount = product?.priceAmount;
142
+ if (typeof amount !== "number" || !Number.isFinite(amount)) {
143
+ return undefined;
144
+ }
145
+ const period = resolveBillingPeriod(product);
146
+ if (!period) {
147
+ return undefined;
148
+ }
149
+ const periodUnitDays = PERIOD_UNIT_DAYS[period.unit];
150
+ const targetDays = PERIOD_UNIT_DAYS[unit];
151
+ if (!periodUnitDays || !targetDays) {
152
+ return undefined;
153
+ }
154
+ const periodDays = periodUnitDays * period.count;
155
+ if (periodDays <= 0) {
156
+ return undefined;
157
+ }
158
+ const perUnit = (amount / periodDays) * targetDays;
159
+ return {
160
+ amount: perUnit,
161
+ formatted: formatPrice(perUnit, product?.currency, options?.locale),
162
+ };
163
+ }
164
+ /**
165
+ * The product's renewal period, preferring the adapter-normalized
166
+ * `billingPeriod` and falling back to parsing the raw store string.
167
+ */
168
+ export function resolveBillingPeriod(product) {
169
+ return product?.billingPeriod ?? parseBillingPeriod(product?.period);
170
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onborn/billing",
3
- "version": "0.1.0-beta.4",
3
+ "version": "0.1.0-beta.6",
4
4
  "description": "Headless billing, offerings, purchases, restores, and entitlements for Onborn.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -46,8 +46,8 @@
46
46
  "prepack": "yarn build"
47
47
  },
48
48
  "dependencies": {
49
- "@onborn/analytics": "0.1.0-beta.2",
50
- "@onborn/sdk-contracts": "0.1.0-beta.1"
49
+ "@onborn/analytics": "0.1.0-beta.3",
50
+ "@onborn/sdk-contracts": "0.1.0-beta.3"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "expo-iap": ">=4.5.0",