@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 CHANGED
@@ -1,3 +1,90 @@
1
- # Temporary Holding Version
1
+ # @vrs-pay/js
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > **Lightweight, framework-agnostic browser SDK for VRS Pay.**
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@vrs-pay/js.svg)](https://www.npmjs.com/package/@vrs-pay/js)
6
+ [![license](https://img.shields.io/npm/l/@vrs-pay/js.svg)](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)
@@ -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"}
@@ -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"}
@@ -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"}
@@ -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
+ };
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
@@ -0,0 +1,9 @@
1
+ export {
2
+ createVrsPayClient,
3
+ type Fetch,
4
+ type VrsPayClient,
5
+ type VrsPayClientOptions,
6
+ } from "./client";
7
+ export { VrsPayError } from "./errors";
8
+ export { formatAmount, formatPrice } from "./format";
9
+ export type * from "./types";
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
+ }