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