@onborn/billing 0.1.0-beta.1

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.
@@ -0,0 +1,345 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { createBillingClient } from "./client";
3
+ import { useOnbornBillingConfig } from "./runtime";
4
+ import { findPackageWithProduct, getPackagesWithProducts, resolveDefaultPackageId, } from "./utils";
5
+ import { validateBillingPurchase, validateBillingRestore } from "./validation";
6
+ export function useOnbornPaywall(options) {
7
+ const runtimeOptions = useOnbornBillingConfig(options);
8
+ const [data, setData] = useState(null);
9
+ const [selectedPackageId, setSelectedPackageId] = useState(runtimeOptions.initialPackageId ?? null);
10
+ const [loading, setLoading] = useState(true);
11
+ const [purchasing, setPurchasing] = useState(false);
12
+ const [restoring, setRestoring] = useState(false);
13
+ const [error, setError] = useState(null);
14
+ const client = useMemo(() => createBillingClient({
15
+ sourceId: `paywall:${runtimeOptions.paywallId}`,
16
+ }), [
17
+ runtimeOptions.apiKey,
18
+ runtimeOptions.appId,
19
+ runtimeOptions.appVersion,
20
+ runtimeOptions.autoFlushMs,
21
+ runtimeOptions.country,
22
+ runtimeOptions.emitAnalyticsEvents,
23
+ runtimeOptions.emitSdkConnectionSignal,
24
+ runtimeOptions.fetchImpl,
25
+ runtimeOptions.locale,
26
+ runtimeOptions.paywallId,
27
+ runtimeOptions.platform,
28
+ runtimeOptions.sdkVersion,
29
+ runtimeOptions.userId,
30
+ runtimeOptions.userType,
31
+ ]);
32
+ const packages = useMemo(() => getPackagesWithProducts(data?.offering, data?.products, runtimeOptions.platform), [data?.offering, data?.products, runtimeOptions.platform]);
33
+ const selectedPackage = useMemo(() => {
34
+ const fallbackPackageId = selectedPackageId ?? resolveDefaultPackageId(data?.offering);
35
+ return findPackageWithProduct(packages, fallbackPackageId) ?? null;
36
+ }, [data?.offering, packages, selectedPackageId]);
37
+ const load = useCallback(async () => {
38
+ setLoading(true);
39
+ setError(null);
40
+ try {
41
+ const response = await client.loadPaywall(runtimeOptions.paywallId);
42
+ const products = await loadLocalizedProducts(runtimeOptions.billingAdapter, {
43
+ paywall: response.paywall,
44
+ offering: response.offering,
45
+ products: response.products,
46
+ userId: runtimeOptions.userId,
47
+ });
48
+ setData({ ...response, products });
49
+ setSelectedPackageId((current) => {
50
+ if (current &&
51
+ response.offering?.packages.some((item) => item.id === current)) {
52
+ return current;
53
+ }
54
+ return resolveDefaultPackageId(response.offering) ?? null;
55
+ });
56
+ }
57
+ catch (loadError) {
58
+ setError(toErrorMessage(loadError));
59
+ }
60
+ finally {
61
+ setLoading(false);
62
+ }
63
+ }, [client, runtimeOptions.billingAdapter, runtimeOptions.paywallId, runtimeOptions.userId]);
64
+ useEffect(() => {
65
+ void load();
66
+ }, [load]);
67
+ const selectPackage = useCallback((packageId) => {
68
+ setSelectedPackageId(packageId);
69
+ if (!data?.paywall) {
70
+ return;
71
+ }
72
+ const product = findPackageWithProduct(packages, packageId)?.product;
73
+ void client
74
+ .trackPaywallPackageSelected({
75
+ sessionId: `paywall:${data.paywall.id}`,
76
+ stepId: `paywall:${data.paywall.id}:screen`,
77
+ paywallId: data.paywall.id,
78
+ paywallTemplate: data.paywall.name,
79
+ packageId,
80
+ productId: product?.storeProductId,
81
+ })
82
+ .then(() => client.flushEvents())
83
+ .catch(() => { });
84
+ }, [client, data, packages]);
85
+ const purchasePackageInternal = useCallback(async (packageId, intent = "purchase") => {
86
+ const item = findPackageWithProduct(packages, packageId) ?? selectedPackage;
87
+ if (!item || !data?.offering) {
88
+ throw new Error("No paywall package selected");
89
+ }
90
+ if (!runtimeOptions.billingAdapter) {
91
+ throw new Error("Missing ONBORN billingAdapter. Provide one to purchase from a custom paywall.");
92
+ }
93
+ if (intent === "trial") {
94
+ try {
95
+ const shouldContinue = await runtimeOptions.onStartTrial?.(item);
96
+ if (shouldContinue === false) {
97
+ return { success: false, packageId: item.package.id };
98
+ }
99
+ }
100
+ catch (trialError) {
101
+ const errorObject = toError(trialError);
102
+ runtimeOptions.onPurchaseFailed?.(errorObject);
103
+ throw errorObject;
104
+ }
105
+ }
106
+ setPurchasing(true);
107
+ runtimeOptions.onPurchaseStarted?.(item);
108
+ const paywallSessionId = `paywall:${data.paywall.id}`;
109
+ const paywallStepId = `paywall:${data.paywall.id}:screen`;
110
+ const purchaseProductId = item.product?.storeProductId ?? item.package.productId;
111
+ void client
112
+ .trackPaywallPurchaseStarted({
113
+ sessionId: paywallSessionId,
114
+ stepId: paywallStepId,
115
+ paywallId: data.paywall.id,
116
+ paywallTemplate: data.paywall.name,
117
+ packageId: item.package.id,
118
+ productId: purchaseProductId,
119
+ })
120
+ .then(() => client.flushEvents())
121
+ .catch(() => { });
122
+ if (intent === "trial") {
123
+ void client
124
+ .trackPaywallTrialStarted({
125
+ sessionId: paywallSessionId,
126
+ stepId: paywallStepId,
127
+ paywallId: data.paywall.id,
128
+ paywallTemplate: data.paywall.name,
129
+ packageId: item.package.id,
130
+ productId: purchaseProductId,
131
+ trialPeriod: item.product?.trialPeriod,
132
+ })
133
+ .then(() => client.flushEvents())
134
+ .catch(() => { });
135
+ }
136
+ try {
137
+ const adapterResult = await runtimeOptions.billingAdapter.purchasePackage({
138
+ paywall: data.paywall,
139
+ offering: data.offering,
140
+ package: item.package,
141
+ product: item.product,
142
+ userId: runtimeOptions.userId,
143
+ });
144
+ const result = adapterResult.success
145
+ ? await validateBillingPurchase({
146
+ client,
147
+ paywall: data.paywall,
148
+ offering: data.offering,
149
+ item,
150
+ result: adapterResult,
151
+ })
152
+ : adapterResult;
153
+ runtimeOptions.onPurchaseCompleted?.(result);
154
+ notifyEntitlementsChanged(result.entitlements, runtimeOptions.onEntitlementsChanged);
155
+ if (result.success && result.status === "validated") {
156
+ await client.trackPaywallConverted({
157
+ sessionId: paywallSessionId,
158
+ stepId: paywallStepId,
159
+ paywallId: data.paywall.id,
160
+ paywallTemplate: data.paywall.name,
161
+ productId: purchaseProductId,
162
+ });
163
+ await client.flushEvents();
164
+ }
165
+ else if (!result.success) {
166
+ void client
167
+ .trackPaywallPurchaseFailed({
168
+ sessionId: paywallSessionId,
169
+ stepId: paywallStepId,
170
+ paywallId: data.paywall.id,
171
+ paywallTemplate: data.paywall.name,
172
+ reason: "error",
173
+ packageId: item.package.id,
174
+ productId: purchaseProductId,
175
+ })
176
+ .then(() => client.flushEvents())
177
+ .catch(() => { });
178
+ }
179
+ return result;
180
+ }
181
+ catch (purchaseError) {
182
+ const errorObject = toError(purchaseError);
183
+ runtimeOptions.onPurchaseFailed?.(errorObject);
184
+ void client
185
+ .trackPaywallPurchaseFailed({
186
+ sessionId: paywallSessionId,
187
+ stepId: paywallStepId,
188
+ paywallId: data.paywall.id,
189
+ paywallTemplate: data.paywall.name,
190
+ reason: classifyPurchaseFailureReason(purchaseError),
191
+ packageId: item.package.id,
192
+ productId: purchaseProductId,
193
+ message: errorObject.message,
194
+ })
195
+ .then(() => client.flushEvents())
196
+ .catch(() => { });
197
+ throw errorObject;
198
+ }
199
+ finally {
200
+ setPurchasing(false);
201
+ }
202
+ }, [client, data, runtimeOptions, packages, selectedPackage]);
203
+ const purchasePackage = useCallback((packageId) => purchasePackageInternal(packageId, "purchase"), [purchasePackageInternal]);
204
+ const startTrialPackage = useCallback((packageId) => purchasePackageInternal(packageId, "trial"), [purchasePackageInternal]);
205
+ const restorePurchases = useCallback(async () => {
206
+ if (!runtimeOptions.billingAdapter?.restorePurchases) {
207
+ throw new Error("Missing restorePurchases on ONBORN billingAdapter.");
208
+ }
209
+ setRestoring(true);
210
+ const restorePaywall = data?.paywall;
211
+ if (restorePaywall) {
212
+ void client
213
+ .trackPaywallRestoreStarted({
214
+ sessionId: `paywall:${restorePaywall.id}`,
215
+ stepId: `paywall:${restorePaywall.id}:screen`,
216
+ paywallId: restorePaywall.id,
217
+ paywallTemplate: restorePaywall.name,
218
+ })
219
+ .then(() => client.flushEvents())
220
+ .catch(() => { });
221
+ }
222
+ try {
223
+ const adapterResult = await runtimeOptions.billingAdapter.restorePurchases({
224
+ paywall: data?.paywall,
225
+ offering: data?.offering,
226
+ products: data?.products ?? [],
227
+ userId: runtimeOptions.userId,
228
+ });
229
+ const result = await validateBillingRestore({
230
+ client,
231
+ offering: data?.offering,
232
+ result: adapterResult,
233
+ });
234
+ runtimeOptions.onRestoreCompleted?.(result);
235
+ notifyEntitlementsChanged(result.entitlements, runtimeOptions.onEntitlementsChanged);
236
+ if (restorePaywall) {
237
+ void client
238
+ .trackPaywallRestoreCompleted({
239
+ sessionId: `paywall:${restorePaywall.id}`,
240
+ stepId: `paywall:${restorePaywall.id}:screen`,
241
+ paywallId: restorePaywall.id,
242
+ paywallTemplate: restorePaywall.name,
243
+ restored: Boolean(result.success),
244
+ })
245
+ .then(() => client.flushEvents())
246
+ .catch(() => { });
247
+ }
248
+ return result;
249
+ }
250
+ catch (restoreError) {
251
+ const errorObject = toError(restoreError);
252
+ runtimeOptions.onRestoreFailed?.(errorObject);
253
+ if (restorePaywall) {
254
+ void client
255
+ .trackPaywallRestoreFailed({
256
+ sessionId: `paywall:${restorePaywall.id}`,
257
+ stepId: `paywall:${restorePaywall.id}:screen`,
258
+ paywallId: restorePaywall.id,
259
+ paywallTemplate: restorePaywall.name,
260
+ message: errorObject.message,
261
+ })
262
+ .then(() => client.flushEvents())
263
+ .catch(() => { });
264
+ }
265
+ throw errorObject;
266
+ }
267
+ finally {
268
+ setRestoring(false);
269
+ }
270
+ }, [client, data, runtimeOptions]);
271
+ const refetchCustomerEntitlements = useCallback(async () => {
272
+ if (!runtimeOptions.billingAdapter?.refetchCustomerEntitlements) {
273
+ throw new Error("Missing refetchCustomerEntitlements on ONBORN billingAdapter.");
274
+ }
275
+ const adapterResult = await runtimeOptions.billingAdapter.refetchCustomerEntitlements({
276
+ userId: runtimeOptions.userId,
277
+ });
278
+ const result = await validateBillingRestore({
279
+ client,
280
+ offering: data?.offering,
281
+ result: adapterResult,
282
+ });
283
+ notifyEntitlementsChanged(result.entitlements, runtimeOptions.onEntitlementsChanged);
284
+ return result;
285
+ }, [client, data?.offering, runtimeOptions]);
286
+ return {
287
+ data,
288
+ packages,
289
+ selectedPackage,
290
+ selectedPackageId: selectedPackage?.package.id ?? null,
291
+ loading,
292
+ purchasing,
293
+ restoring,
294
+ error,
295
+ selectPackage,
296
+ reload: load,
297
+ purchasePackage,
298
+ startTrialPackage,
299
+ restorePurchases,
300
+ refetchCustomerEntitlements,
301
+ };
302
+ }
303
+ function toErrorMessage(error) {
304
+ return toError(error).message;
305
+ }
306
+ function toError(error) {
307
+ return error instanceof Error ? error : new Error("Unknown error");
308
+ }
309
+ function classifyPurchaseFailureReason(error) {
310
+ if (error && typeof error === "object") {
311
+ const candidate = error;
312
+ if (candidate.userCancelled === true ||
313
+ candidate.userCancelled === "true") {
314
+ return "cancelled";
315
+ }
316
+ const code = typeof candidate.code === "string" ? candidate.code.toUpperCase() : "";
317
+ if (code.includes("CANCEL")) {
318
+ return "cancelled";
319
+ }
320
+ const message = typeof candidate.message === "string"
321
+ ? candidate.message.toLowerCase()
322
+ : "";
323
+ if (message.includes("cancel")) {
324
+ return "cancelled";
325
+ }
326
+ }
327
+ return "error";
328
+ }
329
+ async function loadLocalizedProducts(billingAdapter, input) {
330
+ if (!billingAdapter?.loadProducts) {
331
+ return input.products;
332
+ }
333
+ try {
334
+ return await billingAdapter.loadProducts(input);
335
+ }
336
+ catch {
337
+ return input.products;
338
+ }
339
+ }
340
+ function notifyEntitlementsChanged(entitlements, callback) {
341
+ if (!entitlements) {
342
+ return;
343
+ }
344
+ callback?.(entitlements);
345
+ }
@@ -0,0 +1,8 @@
1
+ import type { BillingOffering, BillingPackage, BillingPlatform, BillingProduct } from "@onborn/sdk-contracts";
2
+ import type { OnbornPackageWithProduct } from "./types";
3
+ export declare function getPackagesWithProducts(offering: BillingOffering | undefined, products: BillingProduct[] | undefined, platform?: BillingPlatform): OnbornPackageWithProduct[];
4
+ export declare function resolveDefaultPackageId(offering: BillingOffering | undefined): string | undefined;
5
+ export declare function findPackageWithProduct(packages: OnbornPackageWithProduct[], packageId: string | undefined): OnbornPackageWithProduct | undefined;
6
+ export declare function formatPackagePrice(product: BillingProduct | undefined, fallback?: string): string;
7
+ export declare function getStoreProductId(billingPackage: BillingPackage, product: BillingProduct | undefined): string;
8
+ export declare function formatBillingPeriod(period: string | undefined): string;
package/dist/utils.js ADDED
@@ -0,0 +1,58 @@
1
+ import { resolvePackageProductId } from "@onborn/sdk-contracts";
2
+ export function getPackagesWithProducts(offering, products, platform) {
3
+ if (!offering) {
4
+ return [];
5
+ }
6
+ const productList = products ?? [];
7
+ return [...offering.packages]
8
+ .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
9
+ .map((billingPackage) => ({
10
+ package: billingPackage,
11
+ product: productList.find((product) => product.id === resolvePackageProductId(billingPackage, platform)),
12
+ }));
13
+ }
14
+ export function resolveDefaultPackageId(offering) {
15
+ if (!offering) {
16
+ return undefined;
17
+ }
18
+ return (offering.defaultPackageId ??
19
+ offering.packages.find((item) => item.isHighlighted)?.id ??
20
+ offering.packages[0]?.id);
21
+ }
22
+ export function findPackageWithProduct(packages, packageId) {
23
+ if (!packageId) {
24
+ return undefined;
25
+ }
26
+ return packages.find((item) => item.package.id === packageId);
27
+ }
28
+ export function formatPackagePrice(product, fallback = "") {
29
+ if (!product?.price) {
30
+ return fallback;
31
+ }
32
+ const period = formatBillingPeriod(product.period);
33
+ return period ? `${product.price} / ${period}` : product.price;
34
+ }
35
+ export function getStoreProductId(billingPackage, product) {
36
+ return product?.storeProductId ?? product?.id ?? billingPackage.productId;
37
+ }
38
+ export function formatBillingPeriod(period) {
39
+ switch (period?.toLowerCase()) {
40
+ case "p1w":
41
+ case "one_week":
42
+ return "week";
43
+ case "p1m":
44
+ case "one_month":
45
+ return "month";
46
+ case "p3m":
47
+ case "three_months":
48
+ return "3 months";
49
+ case "p6m":
50
+ case "six_months":
51
+ return "6 months";
52
+ case "p1y":
53
+ case "one_year":
54
+ return "year";
55
+ default:
56
+ return period ?? "";
57
+ }
58
+ }
@@ -0,0 +1,20 @@
1
+ import type { PaywallConfig } from "@onborn/sdk-contracts";
2
+ import type { BillingClient } from "./client";
3
+ import type { OnbornPackageWithProduct, OnbornPurchaseResult, OnbornRestoreResult } from "./types";
4
+ import type { BillingOffering } from "@onborn/sdk-contracts";
5
+ export type BillingValidationClient = Pick<BillingClient, "validatePurchase" | "restorePurchases">;
6
+ type ValidateBillingPurchaseInput = {
7
+ client: BillingValidationClient;
8
+ paywall?: PaywallConfig;
9
+ offering: BillingOffering;
10
+ item: OnbornPackageWithProduct;
11
+ result: OnbornPurchaseResult;
12
+ };
13
+ type ValidateBillingRestoreInput = {
14
+ client: BillingValidationClient;
15
+ offering?: BillingOffering;
16
+ result: OnbornRestoreResult;
17
+ };
18
+ export declare function validateBillingPurchase(input: ValidateBillingPurchaseInput): Promise<OnbornPurchaseResult>;
19
+ export declare function validateBillingRestore(input: ValidateBillingRestoreInput): Promise<OnbornRestoreResult>;
20
+ export {};
@@ -0,0 +1,60 @@
1
+ export async function validateBillingPurchase(input) {
2
+ const product = input.item.product;
3
+ if (!product) {
4
+ throw new Error(`Missing product for ONBORN package '${input.item.package.id}'.`);
5
+ }
6
+ const response = await input.client.validatePurchase({
7
+ idempotencyKey: createPurchaseIdempotencyKey(input),
8
+ paywallId: input.paywall?.id,
9
+ offeringId: input.offering.id,
10
+ packageId: input.item.package.id,
11
+ productId: product.id,
12
+ storeProductId: product.storeProductId,
13
+ provider: input.offering.provider,
14
+ store: product.store,
15
+ transactionId: input.result.transactionId,
16
+ purchaseToken: input.result.purchaseToken,
17
+ receipt: input.result.receipt,
18
+ raw: input.result.raw,
19
+ });
20
+ return {
21
+ ...input.result,
22
+ status: response.status,
23
+ purchaseId: response.purchaseId,
24
+ entitlements: response.entitlements,
25
+ entitlementIds: response.entitlements.map((item) => item.key),
26
+ };
27
+ }
28
+ export async function validateBillingRestore(input) {
29
+ const response = await input.client.restorePurchases({
30
+ idempotencyKey: createRestoreIdempotencyKey(input),
31
+ provider: input.offering?.provider,
32
+ activeEntitlementKeys: input.result.entitlementIds,
33
+ activeProductIds: input.result.activeProductIds,
34
+ purchases: input.result.purchases,
35
+ raw: input.result.raw,
36
+ });
37
+ return {
38
+ ...input.result,
39
+ status: response.status,
40
+ purchaseId: response.purchaseId,
41
+ entitlements: response.entitlements,
42
+ entitlementIds: response.entitlements.map((item) => item.key),
43
+ };
44
+ }
45
+ function createPurchaseIdempotencyKey(input) {
46
+ const stablePart = input.result.transactionId ??
47
+ input.result.purchaseToken ??
48
+ input.result.productId ??
49
+ input.item.product?.storeProductId ??
50
+ input.item.package.id;
51
+ return [
52
+ "purchase",
53
+ input.offering.id,
54
+ input.item.package.id,
55
+ stablePart,
56
+ ].join(":");
57
+ }
58
+ function createRestoreIdempotencyKey(input) {
59
+ return ["restore", input.offering?.id ?? "unknown", String(Date.now())].join(":");
60
+ }
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@onborn/billing",
3
+ "version": "0.1.0-beta.1",
4
+ "description": "Headless billing, offerings, purchases, restores, and entitlements for Onborn.",
5
+ "license": "UNLICENSED",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/onborn/onborn-react-native.git",
9
+ "directory": "packages/billing"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/onborn/onborn-react-native/issues"
13
+ },
14
+ "homepage": "https://github.com/onborn/onborn-react-native#readme",
15
+ "private": false,
16
+ "type": "module",
17
+ "main": "./dist/index.js",
18
+ "types": "./dist/index.d.ts",
19
+ "react-native": "./dist/index.js",
20
+ "exports": {
21
+ ".": {
22
+ "react-native": "./dist/index.js",
23
+ "types": "./dist/index.d.ts",
24
+ "default": "./dist/index.js"
25
+ }
26
+ },
27
+ "files": [
28
+ "dist/**/*.js",
29
+ "dist/**/*.d.ts",
30
+ "README.md",
31
+ "CHANGELOG.md",
32
+ "LICENSE"
33
+ ],
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "scripts": {
38
+ "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc",
39
+ "lint": "eslint . --max-warnings 0",
40
+ "check-types": "tsc --noEmit",
41
+ "prepack": "yarn build"
42
+ },
43
+ "dependencies": {
44
+ "@onborn/analytics": "0.1.0-beta.2",
45
+ "@onborn/sdk-contracts": "0.1.0-beta.1"
46
+ },
47
+ "peerDependencies": {
48
+ "react": ">=18.0.0",
49
+ "react-native": ">=0.76.0"
50
+ },
51
+ "devDependencies": {
52
+ "@repo/eslint-config": "*",
53
+ "@repo/typescript-config": "*",
54
+ "@types/node": "^22.15.3",
55
+ "@types/react": "~19.2.4",
56
+ "eslint": "^9.39.1",
57
+ "react": "19.2.3",
58
+ "react-native": "0.86.0",
59
+ "typescript": "5.9.2"
60
+ }
61
+ }