@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 +15 -0
- package/README.md +6 -1
- package/dist/adapters/nativeStores.d.ts +5 -1
- package/dist/adapters/nativeStores.js +20 -0
- package/dist/errors.d.ts +53 -0
- package/dist/errors.js +103 -0
- package/dist/expo-iap.d.ts +21 -0
- package/dist/expo-iap.js +227 -26
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/useOnbornEntitlements.d.ts +14 -0
- package/dist/useOnbornEntitlements.js +46 -3
- package/dist/useOnbornOffering.js +11 -0
- package/dist/utils.d.ts +36 -1
- package/dist/utils.js +112 -0
- package/package.json +3 -3
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
|
|
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
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/expo-iap.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
211
|
+
const eligibility = await resolveIntroOfferEligibility(nativeProducts);
|
|
212
|
+
return nativeProducts.map((product) => normalizeStoreProduct(product, eligibility));
|
|
142
213
|
},
|
|
143
214
|
async purchaseProduct({ storeProductId }) {
|
|
144
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
package/dist/index.js
CHANGED
|
@@ -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.
|
|
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.
|
|
50
|
-
"@onborn/sdk-contracts": "0.1.0-beta.
|
|
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",
|