glyphpay 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 CHANGED
@@ -1,3 +1,107 @@
1
- # Temporary Holding Version
1
+ # glyphpay
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
+ Drop-in connector that lets a website take payments through [GlyphPay](https://glyphpay.net) by Design Glyph. Payments go to the site owner's own Stripe connected account.
4
+
5
+ | Import | Runs on | What it gives you |
6
+ | --- | --- | --- |
7
+ | `glyphpay/server` | Site's server | `glyphPay()`: `checkout` and `webhook` route handlers, plus `createPayment`, `getPayment`, `refund`, `reconcile` |
8
+ | `glyphpay/react` | Browser | `<GlyphPayCheckout />` |
9
+ | `glyphpay/browser` | Browser, any site | `mountCheckout(el, options)` for sites without React |
10
+
11
+ ## 1. Connect the client in Design Glyph admin
12
+
13
+ 1. **Admin → Clients → (client)**: make sure Stripe onboarding is complete (charges enabled).
14
+ 2. **Issue a service credential** (test first). Copy the `dgsvc_test_…` token. It is shown only once.
15
+ 3. **Set the callback URL** to `https://<client-site>/api/glyphpay/webhook`. Copy the signing secret. It is also shown only once.
16
+
17
+ ## 2. Add to the client site
18
+
19
+ ```sh
20
+ pnpm add glyphpay
21
+ ```
22
+
23
+ `.env` (server only, never `NEXT_PUBLIC_`):
24
+
25
+ ```sh
26
+ GLYPHPAY_TOKEN=dgsvc_test_...
27
+ GLYPHPAY_CALLBACK_SECRET=...
28
+ ```
29
+
30
+ `lib/glyphpay.ts`
31
+
32
+ ```ts
33
+ import { glyphPay, CheckoutError } from "glyphpay/server";
34
+
35
+ export const glyphpay = glyphPay({
36
+ token: process.env.GLYPHPAY_TOKEN,
37
+ callbackSecret: process.env.GLYPHPAY_CALLBACK_SECRET,
38
+
39
+ // Price the order from YOUR data. Never trust an amount sent by the browser.
40
+ async priceOrder(body) {
41
+ const cart = await db.carts.get((body as { cartId: string }).cartId);
42
+ if (!cart?.items.length) throw new CheckoutError("Your cart is empty.");
43
+ // orderId must change whenever the total changes, or GlyphPay returns 409.
44
+ return { orderId: `${cart.id}-v${cart.version}`, amountCents: cart.totalCents, description: `Order ${cart.id}` };
45
+ },
46
+
47
+ // Signed and verified. At-least-once delivery: dedupe on event.id. Throw to have it retried.
48
+ async onEvent(event) {
49
+ if (event.type === "payment.succeeded") await db.orders.markPaid(event.data.orderId, event.id);
50
+ },
51
+ });
52
+ ```
53
+
54
+ `app/api/glyphpay/checkout/route.ts`
55
+
56
+ ```ts
57
+ import { glyphpay } from "@/lib/glyphpay";
58
+ export const POST = glyphpay.checkout;
59
+ ```
60
+
61
+ `app/api/glyphpay/webhook/route.ts`
62
+
63
+ ```ts
64
+ import { glyphpay } from "@/lib/glyphpay";
65
+ export const POST = glyphpay.webhook;
66
+ ```
67
+
68
+ Checkout page:
69
+
70
+ ```tsx
71
+ import { GlyphPayCheckout } from "glyphpay/react";
72
+
73
+ <GlyphPayCheckout order={{ cartId }} returnUrl={`/thank-you?cart=${cartId}`} appearance={{ theme: "stripe" }} />
74
+ ```
75
+
76
+ Style the button with `.glyphpay button { … }`. Errors appear in `.glyphpay [role=alert]`.
77
+
78
+ Without React:
79
+
80
+ ```html
81
+ <div id="pay"></div>
82
+ <script type="module">
83
+ import { mountCheckout } from "glyphpay/browser";
84
+ mountCheckout(document.getElementById("pay"), { order: { cartId: "42" }, returnUrl: "/thank-you" });
85
+ </script>
86
+ ```
87
+
88
+ The route handlers take a standard `Request` and return a `Response`, so the same two lines work in Remix, Astro, Hono, SvelteKit, and Cloudflare Workers. In Express, use `createPayment` and `verifyCallback` directly.
89
+
90
+ ## How a payment flows
91
+
92
+ 1. The component POSTs `order` to `/api/glyphpay/checkout`. `priceOrder` sets the amount, and the site's server calls GlyphPay with the token.
93
+ 2. The browser gets back only `clientSecret`, `publishableKey`, and `stripeAccount`, then mounts Stripe's Payment Element.
94
+ 3. The shopper pays, and Stripe redirects to `returnUrl`. **The redirect doesn't prove payment.**
95
+ 4. GlyphPay POSTs `payment.succeeded` to `/api/glyphpay/webhook`, and `onEvent` marks the order paid. The thank-you page reads the order status from your database, or calls `glyphpay.getPayment(paymentId)`.
96
+
97
+ Refunds run server-side:
98
+
99
+ ```ts
100
+ await glyphpay.refund(paymentId, { idempotencyKey: `${orderId}-refund-1` });
101
+ ```
102
+
103
+ ## Develop
104
+
105
+ ```sh
106
+ cd packages/glyphpay && npm install && npm test
107
+ ```
@@ -0,0 +1,21 @@
1
+ /**
2
+ * GlyphPay connector, browser half. Framework-free: works on any site.
3
+ * Talks only to the site's own checkout route; the service credential never reaches the browser.
4
+ */
5
+ import { type Appearance } from "@stripe/stripe-js";
6
+ export interface CheckoutOptions {
7
+ /** JSON posted to your checkout route, e.g. `{ cartId }`. Your server prices it; never send a trusted amount. */
8
+ order: unknown;
9
+ /** Where Stripe sends the shopper after paying. Confirm the order server-side there (callback/getPayment), not from the URL. */
10
+ returnUrl: string;
11
+ /** Your site's checkout route. Default `/api/glyphpay/checkout`. */
12
+ endpoint?: string;
13
+ /** Stripe Elements appearance (theme, colors, fonts). */
14
+ appearance?: Appearance;
15
+ buttonText?: string;
16
+ }
17
+ /**
18
+ * Renders `<form class="glyphpay">` (Stripe Payment Element + submit button + `role=alert` message)
19
+ * into `container`. Style it with `.glyphpay button { … }`. Returns an unmount function.
20
+ */
21
+ export declare function mountCheckout(container: HTMLElement, options: CheckoutOptions): () => void;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * GlyphPay connector, browser half. Framework-free: works on any site.
3
+ * Talks only to the site's own checkout route; the service credential never reaches the browser.
4
+ */
5
+ import { loadStripe } from "@stripe/stripe-js";
6
+ /**
7
+ * Renders `<form class="glyphpay">` (Stripe Payment Element + submit button + `role=alert` message)
8
+ * into `container`. Style it with `.glyphpay button { … }`. Returns an unmount function.
9
+ */
10
+ export function mountCheckout(container, options) {
11
+ let unmounted = false;
12
+ const form = document.createElement("form");
13
+ form.className = "glyphpay";
14
+ const slot = document.createElement("div");
15
+ const button = document.createElement("button");
16
+ button.type = "submit";
17
+ button.disabled = true;
18
+ button.textContent = options.buttonText ?? "Pay now";
19
+ const message = document.createElement("p");
20
+ message.setAttribute("role", "alert");
21
+ form.append(slot, button, message);
22
+ container.append(form);
23
+ (async () => {
24
+ const res = await fetch(options.endpoint ?? "/api/glyphpay/checkout", {
25
+ method: "POST",
26
+ headers: { "content-type": "application/json" },
27
+ body: JSON.stringify(options.order ?? null),
28
+ });
29
+ const session = await res.json().catch(() => ({}));
30
+ if (!res.ok)
31
+ throw new Error(session.error ?? "Checkout is unavailable right now.");
32
+ const stripe = await loadStripe(session.publishableKey, { stripeAccount: session.stripeAccount });
33
+ if (!stripe)
34
+ throw new Error("Could not load the payment form.");
35
+ if (unmounted)
36
+ return;
37
+ const elements = stripe.elements({ clientSecret: session.clientSecret, appearance: options.appearance });
38
+ const payment = elements.create("payment");
39
+ payment.on("ready", () => { button.disabled = false; });
40
+ payment.mount(slot);
41
+ form.addEventListener("submit", async (event) => {
42
+ event.preventDefault();
43
+ button.disabled = true;
44
+ message.textContent = "";
45
+ // On success Stripe always redirects to returnUrl (adds payment_intent & redirect_status params).
46
+ const { error } = await stripe.confirmPayment({ elements, confirmParams: { return_url: new URL(options.returnUrl, location.href).href } });
47
+ message.textContent = error.message ?? "Payment failed. Please try again.";
48
+ button.disabled = false;
49
+ });
50
+ })().catch((error) => { message.textContent = error.message; });
51
+ return () => { unmounted = true; form.remove(); };
52
+ }
@@ -0,0 +1,6 @@
1
+ import { type CheckoutOptions } from "./browser.js";
2
+ export type { CheckoutOptions };
3
+ /** Drop-in checkout. Remounts when `order` changes; other props are read once on mount. */
4
+ export declare function GlyphPayCheckout({ className, ...options }: CheckoutOptions & {
5
+ className?: string;
6
+ }): import("react").JSX.Element;
package/dist/react.js ADDED
@@ -0,0 +1,13 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useEffect, useRef } from "react";
4
+ import { mountCheckout } from "./browser.js";
5
+ /** Drop-in checkout. Remounts when `order` changes; other props are read once on mount. */
6
+ export function GlyphPayCheckout({ className, ...options }) {
7
+ const ref = useRef(null);
8
+ const orderKey = JSON.stringify(options.order);
9
+ // ponytail: keyed on order only, so inline appearance/returnUrl objects don't remount Stripe every render.
10
+ // eslint-disable-next-line react-hooks/exhaustive-deps
11
+ useEffect(() => mountCheckout(ref.current, options), [orderKey]);
12
+ return _jsx("div", { ref: ref, className: className });
13
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * GlyphPay connector, server half. Holds the dgsvc_ service credential, so it
3
+ * must only ever run on the client site's server (route handlers, API routes).
4
+ * Runtime-agnostic: uses fetch + Web Crypto (Node 20+, Next.js, edge, Workers).
5
+ */
6
+ export interface GlyphPayOptions {
7
+ /** Service credential from Design Glyph admin (dgsvc_test_… / dgsvc_live_…). Never ship to the browser. */
8
+ token: string | undefined;
9
+ /** Signing secret shown when the callback URL was saved in Design Glyph admin. Needed for `webhook`. */
10
+ callbackSecret?: string;
11
+ /** Defaults to the Design Glyph production origin. */
12
+ baseUrl?: string;
13
+ /**
14
+ * Price the order from YOUR data (DB, cart, CMS). Never trust an amount sent by the browser.
15
+ * `orderId` must change whenever the amount or description changes (e.g. `${cartId}-v${version}`):
16
+ * GlyphPay ties one payment to one orderId and rejects a changed amount with a 409.
17
+ * Throw `CheckoutError` to show the shopper a message.
18
+ */
19
+ priceOrder?: (body: unknown, req: Request) => Promise<Order> | Order;
20
+ /** Runs once per verified callback. Delivery is at-least-once, so dedupe on `event.id`. Throw to have it retried. */
21
+ onEvent?: (event: GlyphPayEvent) => Promise<void> | void;
22
+ }
23
+ export interface Order {
24
+ orderId: string;
25
+ amountCents: number;
26
+ description: string;
27
+ }
28
+ export interface CheckoutSession {
29
+ id: string;
30
+ paymentIntentId: string;
31
+ clientSecret: string;
32
+ publishableKey: string;
33
+ stripeAccount: string;
34
+ }
35
+ export type PaymentStatus = "creating" | "requires_payment_method" | "processing" | "succeeded" | "canceled" | "failed";
36
+ export interface Payment {
37
+ id: string;
38
+ orderId: string;
39
+ status: PaymentStatus;
40
+ amountCents: number;
41
+ applicationFeeCents: number;
42
+ currency: "usd";
43
+ refundedCents: number;
44
+ refunds: {
45
+ stripeRefundId: string | null;
46
+ amountCents: number;
47
+ status: string;
48
+ }[];
49
+ disputes: {
50
+ status: string;
51
+ amountCents: number;
52
+ }[];
53
+ /** True while not terminal. Callbacks (or polling) are the source of truth, never the browser redirect. */
54
+ stale: boolean;
55
+ updatedAt: string;
56
+ }
57
+ export interface Refund {
58
+ paymentId: string;
59
+ refundId: string;
60
+ amountCents: number;
61
+ status: string;
62
+ }
63
+ export interface GlyphPayEvent {
64
+ id: string;
65
+ type: "payment.succeeded" | "payment.failed" | "payment.processing" | "payment.canceled" | "payment.refund_updated" | "payment.dispute_updated";
66
+ createdAt: string;
67
+ livemode: boolean;
68
+ data: Payment;
69
+ }
70
+ /** Error from the GlyphPay API (status/message are the API's). */
71
+ export declare class GlyphPayError extends Error {
72
+ status: number;
73
+ constructor(status: number, message: string);
74
+ }
75
+ /** Throw from `priceOrder` to send `message` to the shopper with a 400. */
76
+ export declare class CheckoutError extends Error {
77
+ }
78
+ export declare function glyphPay(options: GlyphPayOptions): {
79
+ /** Route handler: `export const POST = glyphpay.checkout`. Prices the order, returns what the browser needs. */
80
+ checkout: (req: Request) => Promise<Response>;
81
+ /** Route handler for the callback URL registered in Design Glyph admin: `export const POST = glyphpay.webhook`. */
82
+ webhook: (req: Request) => Promise<Response>;
83
+ /** Idempotent per orderId: calling again returns the same payment. */
84
+ createPayment: (order: Order) => Promise<CheckoutSession>;
85
+ getPayment: (paymentId: string) => Promise<Payment>;
86
+ /** Omit amountCents to refund the remaining balance. Reusing an idempotencyKey replays the same refund. */
87
+ refund: (paymentId: string, input: {
88
+ idempotencyKey: string;
89
+ amountCents?: number;
90
+ }) => Promise<Refund>;
91
+ /** Re-read the payment from Stripe if a callback looks missed. */
92
+ reconcile: (paymentId: string) => Promise<Payment>;
93
+ };
94
+ /** Verifies `Designglyph-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "t.body")>` and returns the event. Rejects >5 min skew. */
95
+ export declare function verifyCallback(rawBody: string, header: string | null, secret: string): Promise<GlyphPayEvent>;
package/dist/server.js ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * GlyphPay connector, server half. Holds the dgsvc_ service credential, so it
3
+ * must only ever run on the client site's server (route handlers, API routes).
4
+ * Runtime-agnostic: uses fetch + Web Crypto (Node 20+, Next.js, edge, Workers).
5
+ */
6
+ /** Error from the GlyphPay API (status/message are the API's). */
7
+ export class GlyphPayError extends Error {
8
+ status;
9
+ constructor(status, message) {
10
+ super(message);
11
+ this.status = status;
12
+ }
13
+ }
14
+ /** Throw from `priceOrder` to send `message` to the shopper with a 400. */
15
+ export class CheckoutError extends Error {
16
+ }
17
+ export function glyphPay(options) {
18
+ const baseUrl = (options.baseUrl ?? "https://designglyph.web.app").replace(/\/$/, "");
19
+ async function call(method, path, body) {
20
+ // ponytail: checked per call, not at construction, so `next build` works without env vars.
21
+ if (!/^dgsvc_(test|live)_/.test(options.token ?? ""))
22
+ throw new GlyphPayError(500, "GlyphPay token is missing or not a dgsvc_ service credential.");
23
+ const res = await fetch(`${baseUrl}/api/client/v1${path}`, {
24
+ method,
25
+ headers: { authorization: `Bearer ${options.token}`, ...(body === undefined ? {} : { "content-type": "application/json" }) },
26
+ body: body === undefined ? undefined : JSON.stringify(body),
27
+ });
28
+ const json = (await res.json().catch(() => null));
29
+ if (!res.ok || !json?.ok)
30
+ throw new GlyphPayError(res.status, json?.error?.message ?? `GlyphPay request failed (HTTP ${res.status}).`);
31
+ return json.data;
32
+ }
33
+ const id = (paymentId) => encodeURIComponent(paymentId);
34
+ const api = {
35
+ /** Idempotent per orderId: calling again returns the same payment. */
36
+ createPayment: (order) => call("POST", "/payments", { ...order, currency: "usd" }),
37
+ getPayment: (paymentId) => call("GET", `/payments/${id(paymentId)}`),
38
+ /** Omit amountCents to refund the remaining balance. Reusing an idempotencyKey replays the same refund. */
39
+ refund: (paymentId, input) => call("POST", `/payments/${id(paymentId)}/refunds`, input),
40
+ /** Re-read the payment from Stripe if a callback looks missed. */
41
+ reconcile: (paymentId) => call("POST", `/payments/${id(paymentId)}/reconcile`),
42
+ };
43
+ return {
44
+ ...api,
45
+ /** Route handler: `export const POST = glyphpay.checkout`. Prices the order, returns what the browser needs. */
46
+ checkout: async (req) => {
47
+ try {
48
+ if (!options.priceOrder)
49
+ throw new Error("GlyphPay: priceOrder is required for checkout.");
50
+ const session = await api.createPayment(await options.priceOrder(await req.json().catch(() => null), req));
51
+ const { clientSecret, publishableKey, stripeAccount } = session;
52
+ return Response.json({ clientSecret, publishableKey, stripeAccount, paymentId: session.id }, { headers: { "cache-control": "no-store" } });
53
+ }
54
+ catch (error) {
55
+ if (error instanceof CheckoutError)
56
+ return Response.json({ error: error.message }, { status: 400 });
57
+ console.error("GlyphPay checkout failed:", error instanceof Error ? error.message : error);
58
+ return Response.json({ error: "Checkout is unavailable right now. Please try again." }, { status: 502 });
59
+ }
60
+ },
61
+ /** Route handler for the callback URL registered in Design Glyph admin: `export const POST = glyphpay.webhook`. */
62
+ webhook: async (req) => {
63
+ // 5xx makes GlyphPay retry (up to 72h), so config and handler errors don't lose events. A bad signature is a permanent 400.
64
+ if (!options.callbackSecret)
65
+ return new Response("GlyphPay callback secret not configured", { status: 500 });
66
+ let event;
67
+ try {
68
+ event = await verifyCallback(await req.text(), req.headers.get("designglyph-signature"), options.callbackSecret);
69
+ }
70
+ catch {
71
+ return new Response("Invalid signature", { status: 400 });
72
+ }
73
+ try {
74
+ await options.onEvent?.(event);
75
+ }
76
+ catch (error) {
77
+ console.error(`GlyphPay event ${event.id} handler failed:`, error instanceof Error ? error.message : error);
78
+ return new Response("Handler failed", { status: 500 });
79
+ }
80
+ return new Response(null, { status: 204 });
81
+ },
82
+ };
83
+ }
84
+ /** Verifies `Designglyph-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "t.body")>` and returns the event. Rejects >5 min skew. */
85
+ export async function verifyCallback(rawBody, header, secret) {
86
+ const { t, v1 } = Object.fromEntries((header ?? "").split(",").map((part) => part.trim().split("=")));
87
+ const enc = new TextEncoder();
88
+ const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
89
+ const signature = /^[0-9a-f]{64}$/.test(v1 ?? "") ? Uint8Array.from(v1.match(/../g), (h) => parseInt(h, 16)) : null;
90
+ const fresh = Math.abs(Date.now() / 1000 - Number(t)) <= 300;
91
+ // subtle.verify compares in constant time.
92
+ if (!signature || !fresh || !(await crypto.subtle.verify("HMAC", key, signature, enc.encode(`${t}.${rawBody}`)))) {
93
+ throw new GlyphPayError(400, "Invalid GlyphPay signature.");
94
+ }
95
+ return JSON.parse(rawBody);
96
+ }
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "glyphpay",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Drop-in GlyphPay checkout for websites: server route handlers plus a React or plain-JS payment form.",
5
+ "homepage": "https://glyphpay.net",
6
+ "keywords": ["glyphpay", "payments", "checkout", "stripe", "react", "nextjs"],
7
+ "license": "UNLICENSED",
8
+ "type": "module",
9
+ "files": ["dist"],
10
+ "exports": {
11
+ "./server": { "types": "./dist/server.d.ts", "default": "./dist/server.js" },
12
+ "./browser": { "types": "./dist/browser.d.ts", "default": "./dist/browser.js" },
13
+ "./react": { "types": "./dist/react.d.ts", "default": "./dist/react.js" }
14
+ },
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "prepare": "tsc",
18
+ "test": "tsc && node --test test/*.test.mjs"
19
+ },
20
+ "dependencies": {
21
+ "@stripe/stripe-js": "^10.2.1"
22
+ },
23
+ "peerDependencies": {
24
+ "react": ">=18"
25
+ },
26
+ "peerDependenciesMeta": {
27
+ "react": { "optional": true }
28
+ },
29
+ "devDependencies": {
30
+ "@types/react": "^19",
31
+ "typescript": "^5"
32
+ }
33
+ }