@vrs-pay/js 0.0.0-stage → 0.1.0
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 +27 -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,28 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @vrs-pay/js
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
VRS Pay in the browser with your publishable key (`pk_test_…` or `pk_live_…`). It shows pricing, starts checkout, runs a customer portal and checks access. It refuses secret keys.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { createVrsPayClient, formatPrice } from "@vrs-pay/js";
|
|
7
|
+
|
|
8
|
+
const vrs = createVrsPayClient({
|
|
9
|
+
publishableKey: "pk_test_…",
|
|
10
|
+
apiUrl: "https://your-vrs-pay-api",
|
|
11
|
+
// Your server makes this with vrs.customerSessions.create in @vrs-pay/sdk.
|
|
12
|
+
// A function is called again when the session expires.
|
|
13
|
+
customerSession: () => fetch("/api/vrs-session").then((r) => r.text()),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const products = await vrs.pricing(); // needs no customer
|
|
17
|
+
const label = formatPrice(products[0].prices[0]); // "£9.00 / month"
|
|
18
|
+
|
|
19
|
+
await vrs.redirectToCheckout({ price: products[0].prices[0].id });
|
|
20
|
+
|
|
21
|
+
const { subscriptions, invoices } = await vrs.customer();
|
|
22
|
+
await vrs.cancelSubscription(subscriptions[0].id); // at period end
|
|
23
|
+
await vrs.resumeSubscription(subscriptions[0].id);
|
|
24
|
+
|
|
25
|
+
if (await vrs.hasFeature("exports")) showExportButton();
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`hasFeature` only decides what to show. Check entitlements on your server before doing paid work.
|
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.0",
|
|
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
|
+
}
|