@vrs-pay/js 0.0.0-stage → 0.1.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.
- package/README.md +89 -2
- package/dist/client.d.ts +38 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/format.d.ts +6 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +108 -0
- package/dist/types.d.ts +80 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +35 -4
- package/src/client.ts +107 -0
- package/src/errors.ts +32 -0
- package/src/format.ts +27 -0
- package/src/index.ts +9 -0
- package/src/types.ts +79 -0
package/README.md
CHANGED
|
@@ -1,3 +1,90 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @vrs-pay/js
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **Lightweight, framework-agnostic browser SDK for VRS Pay.**
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@vrs-pay/js)
|
|
6
|
+
[](https://github.com/vrs-pay/multivrs)
|
|
7
|
+
|
|
8
|
+
`@vrs-pay/js` is the core JavaScript client for VRS Pay in the browser. It works seamlessly with **any frontend framework** (Vanilla JS, React, Vue, Svelte, Angular, Solid) or static websites to load pricing plans, trigger Stripe checkout sessions, query customer billing records, and verify feature entitlements.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Features
|
|
13
|
+
|
|
14
|
+
- 🌐 **Framework-agnostic**: Zero framework dependencies. Use in Vanilla JS, Vue, Svelte, Angular, React, or Astro.
|
|
15
|
+
- 🔒 **Safe client-side access**: Operates strictly using your publishable key (`pk_test_...` or `pk_live_...`). Rejects secret keys to prevent accidental leaks.
|
|
16
|
+
- 💳 **Direct Checkout**: Redirect users straight to Stripe-powered checkout sessions with custom return URLs.
|
|
17
|
+
- 👤 **Customer Self-Service**: Fetch active subscriptions, view past invoices, cancel or resume subscriptions.
|
|
18
|
+
- ⚡ **Feature Entitlements**: Query client-side feature flags and limits based on the user's active tier.
|
|
19
|
+
- 💱 **Price Formatting**: Built-in currency formatting utility (`formatPrice`) with localized currencies and recurring intervals.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install @vrs-pay/js
|
|
27
|
+
# or
|
|
28
|
+
bun add @vrs-pay/js
|
|
29
|
+
# or
|
|
30
|
+
pnpm add @vrs-pay/js
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Quickstart
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { createVrsPayClient, formatPrice } from "@vrs-pay/js";
|
|
39
|
+
|
|
40
|
+
// Initialize the client
|
|
41
|
+
const vrs = createVrsPayClient({
|
|
42
|
+
publishableKey: "pk_test_your_key",
|
|
43
|
+
apiUrl: "https://your-vrs-pay-api.com",
|
|
44
|
+
// Return your server-generated customer session token
|
|
45
|
+
customerSession: async () => {
|
|
46
|
+
const res = await fetch("/api/vrs-session");
|
|
47
|
+
return res.text();
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// 1. Fetch products & pricing tiers (public, no customer required)
|
|
52
|
+
const products = await vrs.pricing();
|
|
53
|
+
const priceString = formatPrice(products[0].prices[0]); // e.g. "$29.00 / month"
|
|
54
|
+
|
|
55
|
+
// 2. Redirect to Stripe Checkout
|
|
56
|
+
await vrs.redirectToCheckout({
|
|
57
|
+
price: products[0].prices[0].id,
|
|
58
|
+
successUrl: "https://example.com/billing?status=success",
|
|
59
|
+
cancelUrl: "https://example.com/pricing",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// 3. Manage customer subscriptions and invoices
|
|
63
|
+
const { subscriptions, invoices } = await vrs.customer();
|
|
64
|
+
if (subscriptions[0]) {
|
|
65
|
+
// Cancel subscription at period end
|
|
66
|
+
await vrs.cancelSubscription(subscriptions[0].id);
|
|
67
|
+
|
|
68
|
+
// Or resume subscription
|
|
69
|
+
await vrs.resumeSubscription(subscriptions[0].id);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 4. Client-side entitlement checks
|
|
73
|
+
if (await vrs.hasFeature("csv_export")) {
|
|
74
|
+
showExportButton();
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## The VRS Pay Ecosystem
|
|
81
|
+
|
|
82
|
+
- **`@vrs-pay/js`** *(This package)*: Universal browser SDK for any frontend.
|
|
83
|
+
- **`@vrs-pay/react`**: Turnkey React 18 & 19 components (`<PricingTable />`, `<PricingComparisonTable />`, `<CustomerPortal />`, `<Gate />`) and hooks.
|
|
84
|
+
- **`@vrs-pay/sdk`**: Server SDK for Node.js, Bun, Cloudflare Workers, and backend API routes.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT © [VRS Pay](https://github.com/vrs-pay)
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { CheckoutParams, CustomerOverview, CustomerSubscription, Entitlements, PricingProduct } from "./types";
|
|
2
|
+
export type Fetch = (input: string, init: RequestInit) => Promise<Response>;
|
|
3
|
+
export interface VrsPayClientOptions {
|
|
4
|
+
/** pk_test_… or pk_live_…; safe to ship to browsers. */
|
|
5
|
+
publishableKey: string;
|
|
6
|
+
/** Your VRS Pay API address. */
|
|
7
|
+
apiUrl: string;
|
|
8
|
+
/**
|
|
9
|
+
* Who the customer is: the `client_secret` your server got from
|
|
10
|
+
* `customerSessions.create`, or a function that fetches a fresh one
|
|
11
|
+
* (sessions last 30 minutes; a function is called again when one expires).
|
|
12
|
+
* Leave it out for pages that only show pricing.
|
|
13
|
+
*/
|
|
14
|
+
customerSession?: string | (() => Promise<string>);
|
|
15
|
+
fetch?: Fetch;
|
|
16
|
+
}
|
|
17
|
+
/** VRS Pay in the browser, with a publishable key. */
|
|
18
|
+
export declare function createVrsPayClient(options: VrsPayClientOptions): {
|
|
19
|
+
/** Active products and prices, for a pricing table. Needs no customer. */
|
|
20
|
+
pricing: () => Promise<PricingProduct[]>;
|
|
21
|
+
/** The customer, their subscriptions, invoices and entitlements, for a portal. */
|
|
22
|
+
customer: () => Promise<CustomerOverview>;
|
|
23
|
+
entitlements: () => Promise<Entitlements>;
|
|
24
|
+
/** Whether the customer has `feature`: true, or a limit above zero. */
|
|
25
|
+
hasFeature: (feature: string) => Promise<boolean>;
|
|
26
|
+
checkout: (params: CheckoutParams) => Promise<{
|
|
27
|
+
id: string;
|
|
28
|
+
url: string;
|
|
29
|
+
}>;
|
|
30
|
+
/** Starts checkout and sends the browser to it. */
|
|
31
|
+
redirectToCheckout: (params: CheckoutParams) => Promise<void>;
|
|
32
|
+
changePlan: (subscriptionId: string, price: string) => Promise<CustomerSubscription>;
|
|
33
|
+
/** Cancels at the end of the period; access continues until then. */
|
|
34
|
+
cancelSubscription: (subscriptionId: string) => Promise<CustomerSubscription>;
|
|
35
|
+
resumeSubscription: (subscriptionId: string) => Promise<CustomerSubscription>;
|
|
36
|
+
};
|
|
37
|
+
export type VrsPayClient = ReturnType<typeof createVrsPayClient>;
|
|
38
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,cAAc,EACd,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,cAAc,EACf,MAAM,SAAS,CAAC;AAEjB,MAAM,MAAM,KAAK,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAE5E,MAAM,WAAW,mBAAmB;IAClC,wDAAwD;IACxD,cAAc,EAAE,MAAM,CAAC;IACvB,gCAAgC;IAChC,MAAM,EAAE,MAAM,CAAC;IACf;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,GAAG,CAAC,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC;IACnD,KAAK,CAAC,EAAE,KAAK,CAAC;CACf;AAID,sDAAsD;AACtD,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,mBAAmB;IAqD3D,0EAA0E;;IAE1E,kFAAkF;;;IAGlF,uEAAuE;0BAC3C,MAAM;uBApBJ,cAAc,KAAG,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC;IAwBnF,mDAAmD;iCAChB,cAAc;iCAGpB,MAAM,SAAS,MAAM;IAElD,qEAAqE;yCAChC,MAAM;yCAEN,MAAM;EAG9C;AAED,MAAM,MAAM,YAAY,GAAG,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC"}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** An error the VRS Pay API returned, with its stable `code`. */
|
|
2
|
+
export declare class VrsPayError extends Error {
|
|
3
|
+
readonly type: string;
|
|
4
|
+
readonly code: string;
|
|
5
|
+
readonly status: number;
|
|
6
|
+
constructor(type: string, code: string, message: string, status: number);
|
|
7
|
+
}
|
|
8
|
+
export declare function errorFromResponse(status: number, body: unknown): VrsPayError;
|
|
9
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,iEAAiE;AACjE,qBAAa,WAAY,SAAQ,KAAK;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;gBAEZ,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CAOxE;AAUD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,WAAW,CAQ5E"}
|
package/dist/format.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PricingPrice } from "./types";
|
|
2
|
+
/** Minor units → "£9.00", "₦15,000.00". */
|
|
3
|
+
export declare function formatAmount(amount: number, currency: string, locale?: string): string;
|
|
4
|
+
/** "£9.00", "£9.00 / month", "£27.00 every 3 months", "£0.10 per unit / month". */
|
|
5
|
+
export declare function formatPrice(price: PricingPrice, currency?: string, locale?: string): string;
|
|
6
|
+
//# sourceMappingURL=format.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../src/format.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAK5C,2CAA2C;AAC3C,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAMtF;AAED,mFAAmF;AACnF,wBAAgB,WAAW,CAAC,KAAK,EAAE,YAAY,EAAE,QAAQ,SAAiB,EAAE,MAAM,CAAC,EAAE,MAAM,UAW1F"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createVrsPayClient, type Fetch, type VrsPayClient, type VrsPayClientOptions, } from "./client";
|
|
2
|
+
export { VrsPayError } from "./errors";
|
|
3
|
+
export { formatAmount, formatPrice } from "./format";
|
|
4
|
+
export type * from "./types";
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kBAAkB,EAClB,KAAK,KAAK,EACV,KAAK,YAAY,EACjB,KAAK,mBAAmB,GACzB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AACvC,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AACrD,mBAAmB,SAAS,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
class VrsPayError extends Error {
|
|
3
|
+
type;
|
|
4
|
+
code;
|
|
5
|
+
status;
|
|
6
|
+
constructor(type, code, message, status) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "VrsPayError";
|
|
9
|
+
this.type = type;
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.status = status;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function isErrorBody(value) {
|
|
15
|
+
return typeof value === "object" && value !== null && "error" in value;
|
|
16
|
+
}
|
|
17
|
+
function errorFromResponse(status, body) {
|
|
18
|
+
const error = isErrorBody(body) ? body.error : undefined;
|
|
19
|
+
return new VrsPayError(error?.type ?? "api_error", error?.code ?? "unexpected_response", error?.message ?? `VRS Pay answered ${status}.`, status);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/client.ts
|
|
23
|
+
var SESSION_HEADER = "VRS-Customer-Session";
|
|
24
|
+
function createVrsPayClient(options) {
|
|
25
|
+
if (options.publishableKey.startsWith("sk_")) {
|
|
26
|
+
throw new Error("VRS Pay: never put a secret key in a browser. Use your publishable key.");
|
|
27
|
+
}
|
|
28
|
+
const base = `${options.apiUrl.replace(/\/+$/, "")}/client/v1`;
|
|
29
|
+
const send = options.fetch ?? ((input, init) => fetch(input, init));
|
|
30
|
+
let session = typeof options.customerSession === "string" ? options.customerSession : null;
|
|
31
|
+
async function sessionSecret(refresh) {
|
|
32
|
+
if (typeof options.customerSession !== "function")
|
|
33
|
+
return session;
|
|
34
|
+
if (!session || refresh)
|
|
35
|
+
session = await options.customerSession();
|
|
36
|
+
return session;
|
|
37
|
+
}
|
|
38
|
+
async function request(method, path, body) {
|
|
39
|
+
for (const refresh of [false, true]) {
|
|
40
|
+
const secret = await sessionSecret(refresh);
|
|
41
|
+
const response = await send(`${base}${path}`, {
|
|
42
|
+
method,
|
|
43
|
+
headers: {
|
|
44
|
+
Authorization: `Bearer ${options.publishableKey}`,
|
|
45
|
+
"Content-Type": "application/json",
|
|
46
|
+
...secret ? { [SESSION_HEADER]: secret } : {}
|
|
47
|
+
},
|
|
48
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
49
|
+
});
|
|
50
|
+
const json = await response.json().catch(() => null);
|
|
51
|
+
if (response.ok)
|
|
52
|
+
return json;
|
|
53
|
+
const canRefresh = response.status === 401 && typeof options.customerSession === "function";
|
|
54
|
+
if (!refresh && canRefresh)
|
|
55
|
+
continue;
|
|
56
|
+
throw errorFromResponse(response.status, json);
|
|
57
|
+
}
|
|
58
|
+
throw new Error("unreachable");
|
|
59
|
+
}
|
|
60
|
+
const here = () => typeof window === "undefined" ? undefined : window.location.href;
|
|
61
|
+
async function checkout(params) {
|
|
62
|
+
const successUrl = params.successUrl ?? here();
|
|
63
|
+
const cancelUrl = params.cancelUrl ?? here();
|
|
64
|
+
if (!successUrl || !cancelUrl)
|
|
65
|
+
throw new Error("VRS Pay: pass successUrl and cancelUrl.");
|
|
66
|
+
return request("POST", "/checkout", {
|
|
67
|
+
price: params.price,
|
|
68
|
+
currency: params.currency,
|
|
69
|
+
quantity: params.quantity,
|
|
70
|
+
success_url: successUrl,
|
|
71
|
+
cancel_url: cancelUrl
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
pricing: async () => (await request("GET", "/pricing")).data,
|
|
76
|
+
customer: () => request("GET", "/customer"),
|
|
77
|
+
entitlements: () => request("GET", "/entitlements"),
|
|
78
|
+
hasFeature: async (feature) => (await request("GET", `/entitlements?feature=${encodeURIComponent(feature)}`)).granted === true,
|
|
79
|
+
checkout,
|
|
80
|
+
redirectToCheckout: async (params) => {
|
|
81
|
+
window.location.assign((await checkout(params)).url);
|
|
82
|
+
},
|
|
83
|
+
changePlan: (subscriptionId, price) => request("POST", `/subscriptions/${subscriptionId}`, { price }),
|
|
84
|
+
cancelSubscription: (subscriptionId) => request("POST", `/subscriptions/${subscriptionId}/cancel`, {}),
|
|
85
|
+
resumeSubscription: (subscriptionId) => request("POST", `/subscriptions/${subscriptionId}/resume`, {})
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// src/format.ts
|
|
89
|
+
var ZERO_DECIMAL = new Set(["jpy"]);
|
|
90
|
+
function formatAmount(amount, currency, locale) {
|
|
91
|
+
const code = currency.toLowerCase();
|
|
92
|
+
const value = ZERO_DECIMAL.has(code) ? amount : amount / 100;
|
|
93
|
+
return new Intl.NumberFormat(locale, { style: "currency", currency: code.toUpperCase() }).format(value);
|
|
94
|
+
}
|
|
95
|
+
function formatPrice(price, currency = price.currency, locale) {
|
|
96
|
+
const option = price.currency_options[currency.toLowerCase()];
|
|
97
|
+
const money = option ? formatAmount(option.amount, currency, locale) : formatAmount(price.amount, price.currency, locale);
|
|
98
|
+
const each = price.usage_type === "metered" ? `${money} per unit` : money;
|
|
99
|
+
if (price.interval === "one_time")
|
|
100
|
+
return each;
|
|
101
|
+
return price.interval_count === 1 ? `${each} / ${price.interval}` : `${each} every ${price.interval_count} ${price.interval}s`;
|
|
102
|
+
}
|
|
103
|
+
export {
|
|
104
|
+
formatPrice,
|
|
105
|
+
formatAmount,
|
|
106
|
+
createVrsPayClient,
|
|
107
|
+
VrsPayError
|
|
108
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** What the browser API returns. Amounts are in minor units (pence, cents, kobo). */
|
|
2
|
+
export type PriceInterval = "one_time" | "day" | "week" | "month" | "year";
|
|
3
|
+
export interface PricingPrice {
|
|
4
|
+
id: string;
|
|
5
|
+
amount: number;
|
|
6
|
+
currency: string;
|
|
7
|
+
currency_options: Record<string, {
|
|
8
|
+
amount: number;
|
|
9
|
+
}>;
|
|
10
|
+
interval: PriceInterval;
|
|
11
|
+
interval_count: number;
|
|
12
|
+
usage_type: "licensed" | "metered";
|
|
13
|
+
lookup_key: string | null;
|
|
14
|
+
}
|
|
15
|
+
export interface PricingProduct {
|
|
16
|
+
id: string;
|
|
17
|
+
object: "product";
|
|
18
|
+
name: string;
|
|
19
|
+
description: string | null;
|
|
20
|
+
images: string[];
|
|
21
|
+
marketing_features: Array<{
|
|
22
|
+
name: string;
|
|
23
|
+
}>;
|
|
24
|
+
trial_days: number;
|
|
25
|
+
features: Record<string, boolean | number>;
|
|
26
|
+
prices: PricingPrice[];
|
|
27
|
+
}
|
|
28
|
+
export interface Entitlements {
|
|
29
|
+
object: "entitlements";
|
|
30
|
+
customer: string;
|
|
31
|
+
plans: string[];
|
|
32
|
+
features: Record<string, boolean | number>;
|
|
33
|
+
feature?: string;
|
|
34
|
+
granted?: boolean;
|
|
35
|
+
}
|
|
36
|
+
export interface CustomerSubscription {
|
|
37
|
+
id: string;
|
|
38
|
+
status: "incomplete" | "trialing" | "active" | "past_due" | "canceled";
|
|
39
|
+
plan: string;
|
|
40
|
+
price: string;
|
|
41
|
+
product_name: string | null;
|
|
42
|
+
price_details: PricingPrice | null;
|
|
43
|
+
quantity: number;
|
|
44
|
+
currency: string;
|
|
45
|
+
current_period_end: number | null;
|
|
46
|
+
trial_end: number | null;
|
|
47
|
+
cancel_at_period_end: boolean;
|
|
48
|
+
pending_price: string | null;
|
|
49
|
+
}
|
|
50
|
+
export interface CustomerInvoice {
|
|
51
|
+
id: string;
|
|
52
|
+
status: "draft" | "open" | "paid" | "void" | "uncollectible";
|
|
53
|
+
currency: string;
|
|
54
|
+
total: number;
|
|
55
|
+
period_start: number;
|
|
56
|
+
period_end: number;
|
|
57
|
+
paid_at: number | null;
|
|
58
|
+
created: number;
|
|
59
|
+
}
|
|
60
|
+
export interface CustomerOverview {
|
|
61
|
+
object: "customer_overview";
|
|
62
|
+
customer: {
|
|
63
|
+
id: string;
|
|
64
|
+
email: string | null;
|
|
65
|
+
name: string | null;
|
|
66
|
+
};
|
|
67
|
+
subscriptions: CustomerSubscription[];
|
|
68
|
+
invoices: CustomerInvoice[];
|
|
69
|
+
entitlements: Entitlements;
|
|
70
|
+
}
|
|
71
|
+
export interface CheckoutParams {
|
|
72
|
+
price: string;
|
|
73
|
+
/** One of the price's currencies; its own by default. */
|
|
74
|
+
currency?: string;
|
|
75
|
+
quantity?: number;
|
|
76
|
+
/** Where to land after paying or giving up; the current page by default. */
|
|
77
|
+
successUrl?: string;
|
|
78
|
+
cancelUrl?: string;
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vrs-pay/js",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "VRS Pay in the browser: pricing, checkout, customer portal and access checks with a publishable key.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"module": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"default": "./src/index.ts"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"src"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "bun build ./src/index.ts --outdir ./dist && tsc --emitDeclarationOnly --declaration --outDir ./dist",
|
|
26
|
+
"prepublishOnly": "bun run build",
|
|
27
|
+
"lint": "biome check .",
|
|
28
|
+
"format": "biome check --fix .",
|
|
29
|
+
"check-types": "tsc --noEmit"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@repo/biome-config": "*",
|
|
33
|
+
"@repo/typescript-config": "*",
|
|
34
|
+
"@types/bun": "^1.3.5",
|
|
35
|
+
"typescript": "5.9.2"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { errorFromResponse } from "./errors";
|
|
2
|
+
import type {
|
|
3
|
+
CheckoutParams,
|
|
4
|
+
CustomerOverview,
|
|
5
|
+
CustomerSubscription,
|
|
6
|
+
Entitlements,
|
|
7
|
+
PricingProduct,
|
|
8
|
+
} from "./types";
|
|
9
|
+
|
|
10
|
+
export type Fetch = (input: string, init: RequestInit) => Promise<Response>;
|
|
11
|
+
|
|
12
|
+
export interface VrsPayClientOptions {
|
|
13
|
+
/** pk_test_… or pk_live_…; safe to ship to browsers. */
|
|
14
|
+
publishableKey: string;
|
|
15
|
+
/** Your VRS Pay API address. */
|
|
16
|
+
apiUrl: string;
|
|
17
|
+
/**
|
|
18
|
+
* Who the customer is: the `client_secret` your server got from
|
|
19
|
+
* `customerSessions.create`, or a function that fetches a fresh one
|
|
20
|
+
* (sessions last 30 minutes; a function is called again when one expires).
|
|
21
|
+
* Leave it out for pages that only show pricing.
|
|
22
|
+
*/
|
|
23
|
+
customerSession?: string | (() => Promise<string>);
|
|
24
|
+
fetch?: Fetch;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SESSION_HEADER = "VRS-Customer-Session";
|
|
28
|
+
|
|
29
|
+
/** VRS Pay in the browser, with a publishable key. */
|
|
30
|
+
export function createVrsPayClient(options: VrsPayClientOptions) {
|
|
31
|
+
if (options.publishableKey.startsWith("sk_")) {
|
|
32
|
+
throw new Error("VRS Pay: never put a secret key in a browser. Use your publishable key.");
|
|
33
|
+
}
|
|
34
|
+
const base = `${options.apiUrl.replace(/\/+$/, "")}/client/v1`;
|
|
35
|
+
const send = options.fetch ?? ((input: string, init: RequestInit) => fetch(input, init));
|
|
36
|
+
let session: string | null =
|
|
37
|
+
typeof options.customerSession === "string" ? options.customerSession : null;
|
|
38
|
+
|
|
39
|
+
async function sessionSecret(refresh: boolean): Promise<string | null> {
|
|
40
|
+
if (typeof options.customerSession !== "function") return session;
|
|
41
|
+
if (!session || refresh) session = await options.customerSession();
|
|
42
|
+
return session;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function request<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
|
|
46
|
+
for (const refresh of [false, true]) {
|
|
47
|
+
const secret = await sessionSecret(refresh);
|
|
48
|
+
const response = await send(`${base}${path}`, {
|
|
49
|
+
method,
|
|
50
|
+
headers: {
|
|
51
|
+
Authorization: `Bearer ${options.publishableKey}`,
|
|
52
|
+
"Content-Type": "application/json",
|
|
53
|
+
...(secret ? { [SESSION_HEADER]: secret } : {}),
|
|
54
|
+
},
|
|
55
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
56
|
+
});
|
|
57
|
+
const json: unknown = await response.json().catch(() => null);
|
|
58
|
+
if (response.ok) return json as T;
|
|
59
|
+
// An expired session: fetch a fresh one once, then try again.
|
|
60
|
+
const canRefresh = response.status === 401 && typeof options.customerSession === "function";
|
|
61
|
+
if (!refresh && canRefresh) continue;
|
|
62
|
+
throw errorFromResponse(response.status, json);
|
|
63
|
+
}
|
|
64
|
+
throw new Error("unreachable");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const here = () => (typeof window === "undefined" ? undefined : window.location.href);
|
|
68
|
+
|
|
69
|
+
async function checkout(params: CheckoutParams): Promise<{ id: string; url: string }> {
|
|
70
|
+
const successUrl = params.successUrl ?? here();
|
|
71
|
+
const cancelUrl = params.cancelUrl ?? here();
|
|
72
|
+
if (!successUrl || !cancelUrl) throw new Error("VRS Pay: pass successUrl and cancelUrl.");
|
|
73
|
+
return request("POST", "/checkout", {
|
|
74
|
+
price: params.price,
|
|
75
|
+
currency: params.currency,
|
|
76
|
+
quantity: params.quantity,
|
|
77
|
+
success_url: successUrl,
|
|
78
|
+
cancel_url: cancelUrl,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
/** Active products and prices, for a pricing table. Needs no customer. */
|
|
84
|
+
pricing: async () => (await request<{ data: PricingProduct[] }>("GET", "/pricing")).data,
|
|
85
|
+
/** The customer, their subscriptions, invoices and entitlements, for a portal. */
|
|
86
|
+
customer: () => request<CustomerOverview>("GET", "/customer"),
|
|
87
|
+
entitlements: () => request<Entitlements>("GET", "/entitlements"),
|
|
88
|
+
/** Whether the customer has `feature`: true, or a limit above zero. */
|
|
89
|
+
hasFeature: async (feature: string) =>
|
|
90
|
+
(await request<Entitlements>("GET", `/entitlements?feature=${encodeURIComponent(feature)}`))
|
|
91
|
+
.granted === true,
|
|
92
|
+
checkout,
|
|
93
|
+
/** Starts checkout and sends the browser to it. */
|
|
94
|
+
redirectToCheckout: async (params: CheckoutParams) => {
|
|
95
|
+
window.location.assign((await checkout(params)).url);
|
|
96
|
+
},
|
|
97
|
+
changePlan: (subscriptionId: string, price: string) =>
|
|
98
|
+
request<CustomerSubscription>("POST", `/subscriptions/${subscriptionId}`, { price }),
|
|
99
|
+
/** Cancels at the end of the period; access continues until then. */
|
|
100
|
+
cancelSubscription: (subscriptionId: string) =>
|
|
101
|
+
request<CustomerSubscription>("POST", `/subscriptions/${subscriptionId}/cancel`, {}),
|
|
102
|
+
resumeSubscription: (subscriptionId: string) =>
|
|
103
|
+
request<CustomerSubscription>("POST", `/subscriptions/${subscriptionId}/resume`, {}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type VrsPayClient = ReturnType<typeof createVrsPayClient>;
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** An error the VRS Pay API returned, with its stable `code`. */
|
|
2
|
+
export class VrsPayError extends Error {
|
|
3
|
+
readonly type: string;
|
|
4
|
+
readonly code: string;
|
|
5
|
+
readonly status: number;
|
|
6
|
+
|
|
7
|
+
constructor(type: string, code: string, message: string, status: number) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "VrsPayError";
|
|
10
|
+
this.type = type;
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.status = status;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ErrorBody {
|
|
17
|
+
error?: { type?: string; code?: string; message?: string };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isErrorBody(value: unknown): value is ErrorBody {
|
|
21
|
+
return typeof value === "object" && value !== null && "error" in value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function errorFromResponse(status: number, body: unknown): VrsPayError {
|
|
25
|
+
const error = isErrorBody(body) ? body.error : undefined;
|
|
26
|
+
return new VrsPayError(
|
|
27
|
+
error?.type ?? "api_error",
|
|
28
|
+
error?.code ?? "unexpected_response",
|
|
29
|
+
error?.message ?? `VRS Pay answered ${status}.`,
|
|
30
|
+
status,
|
|
31
|
+
);
|
|
32
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { PricingPrice } from "./types";
|
|
2
|
+
|
|
3
|
+
/** Currencies without minor units. */
|
|
4
|
+
const ZERO_DECIMAL = new Set(["jpy"]);
|
|
5
|
+
|
|
6
|
+
/** Minor units → "£9.00", "₦15,000.00". */
|
|
7
|
+
export function formatAmount(amount: number, currency: string, locale?: string): string {
|
|
8
|
+
const code = currency.toLowerCase();
|
|
9
|
+
const value = ZERO_DECIMAL.has(code) ? amount : amount / 100;
|
|
10
|
+
return new Intl.NumberFormat(locale, { style: "currency", currency: code.toUpperCase() }).format(
|
|
11
|
+
value,
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** "£9.00", "£9.00 / month", "£27.00 every 3 months", "£0.10 per unit / month". */
|
|
16
|
+
export function formatPrice(price: PricingPrice, currency = price.currency, locale?: string) {
|
|
17
|
+
const option = price.currency_options[currency.toLowerCase()];
|
|
18
|
+
// A currency the price isn't sold in falls back to the price's own.
|
|
19
|
+
const money = option
|
|
20
|
+
? formatAmount(option.amount, currency, locale)
|
|
21
|
+
: formatAmount(price.amount, price.currency, locale);
|
|
22
|
+
const each = price.usage_type === "metered" ? `${money} per unit` : money;
|
|
23
|
+
if (price.interval === "one_time") return each;
|
|
24
|
+
return price.interval_count === 1
|
|
25
|
+
? `${each} / ${price.interval}`
|
|
26
|
+
: `${each} every ${price.interval_count} ${price.interval}s`;
|
|
27
|
+
}
|
package/src/index.ts
ADDED
package/src/types.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** What the browser API returns. Amounts are in minor units (pence, cents, kobo). */
|
|
2
|
+
|
|
3
|
+
export type PriceInterval = "one_time" | "day" | "week" | "month" | "year";
|
|
4
|
+
|
|
5
|
+
export interface PricingPrice {
|
|
6
|
+
id: string;
|
|
7
|
+
amount: number;
|
|
8
|
+
currency: string;
|
|
9
|
+
currency_options: Record<string, { amount: number }>;
|
|
10
|
+
interval: PriceInterval;
|
|
11
|
+
interval_count: number;
|
|
12
|
+
usage_type: "licensed" | "metered";
|
|
13
|
+
lookup_key: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PricingProduct {
|
|
17
|
+
id: string;
|
|
18
|
+
object: "product";
|
|
19
|
+
name: string;
|
|
20
|
+
description: string | null;
|
|
21
|
+
images: string[];
|
|
22
|
+
marketing_features: Array<{ name: string }>;
|
|
23
|
+
trial_days: number;
|
|
24
|
+
features: Record<string, boolean | number>;
|
|
25
|
+
prices: PricingPrice[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface Entitlements {
|
|
29
|
+
object: "entitlements";
|
|
30
|
+
customer: string;
|
|
31
|
+
plans: string[];
|
|
32
|
+
features: Record<string, boolean | number>;
|
|
33
|
+
feature?: string;
|
|
34
|
+
granted?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CustomerSubscription {
|
|
38
|
+
id: string;
|
|
39
|
+
status: "incomplete" | "trialing" | "active" | "past_due" | "canceled";
|
|
40
|
+
plan: string;
|
|
41
|
+
price: string;
|
|
42
|
+
product_name: string | null;
|
|
43
|
+
price_details: PricingPrice | null;
|
|
44
|
+
quantity: number;
|
|
45
|
+
currency: string;
|
|
46
|
+
current_period_end: number | null;
|
|
47
|
+
trial_end: number | null;
|
|
48
|
+
cancel_at_period_end: boolean;
|
|
49
|
+
pending_price: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface CustomerInvoice {
|
|
53
|
+
id: string;
|
|
54
|
+
status: "draft" | "open" | "paid" | "void" | "uncollectible";
|
|
55
|
+
currency: string;
|
|
56
|
+
total: number;
|
|
57
|
+
period_start: number;
|
|
58
|
+
period_end: number;
|
|
59
|
+
paid_at: number | null;
|
|
60
|
+
created: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface CustomerOverview {
|
|
64
|
+
object: "customer_overview";
|
|
65
|
+
customer: { id: string; email: string | null; name: string | null };
|
|
66
|
+
subscriptions: CustomerSubscription[];
|
|
67
|
+
invoices: CustomerInvoice[];
|
|
68
|
+
entitlements: Entitlements;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface CheckoutParams {
|
|
72
|
+
price: string;
|
|
73
|
+
/** One of the price's currencies; its own by default. */
|
|
74
|
+
currency?: string;
|
|
75
|
+
quantity?: number;
|
|
76
|
+
/** Where to land after paying or giving up; the current page by default. */
|
|
77
|
+
successUrl?: string;
|
|
78
|
+
cancelUrl?: string;
|
|
79
|
+
}
|