/** * Veracity Pay – tiny TypeScript client for the Stripe-flavor API. * * Self-contained (no dependencies, works in Node 18+, Next.js route handlers and * edge runtimes). Copy this file into your app, e.g. `lib/veracity-pay.ts`. * * const vp = createVeracityPay({ secretKey: process.env.VERACITY_PAY_SECRET_KEY!, baseUrl: process.env.VERACITY_PAY_BASE_URL! }); * const session = await vp.checkout.sessions.create({ mode: "subscription", line_items: [{ price: "price_…", quantity: 1 }], success_url, cancel_url }); * redirect(session.url!); * * // webhook route * const event = await constructEvent(await req.text(), req.headers.get("veracity-signature"), process.env.VERACITY_PAY_WEBHOOK_SECRET!); */ export type VeracityPayOptions = { secretKey: string; /** e.g. http://localhost:3100 (without /v1) */ baseUrl?: string; fetch?: typeof fetch; }; export type RequestOptions = { idempotencyKey?: string; expand?: string[] }; export type List = { object: "list"; data: T[]; has_more: boolean; url: string }; export type Metadata = Record; export type CheckoutSession = { id: string; object: "checkout.session"; mode: "payment" | "subscription"; status: "open" | "complete" | "expired"; payment_status: "unpaid" | "paid" | "no_payment_required"; url: string | null; amount_total: number; amount_subtotal: number; currency: string; customer: string | null; customer_email: string | null; customer_details: { email: string; name: string | null; phone: string | null; address: Record | null } | null; client_reference_id: string | null; payment_intent: string | PaymentIntent | null; subscription: string | Subscription | null; invoice: string | null; metadata: Metadata; success_url: string | null; cancel_url: string | null; expires_at: number; created: number; }; export type PaymentIntent = { id: string; object: "payment_intent"; amount: number; amount_received: number; amount_refunded: number; currency: string; status: "requires_payment_method" | "requires_action" | "processing" | "succeeded" | "failed" | "canceled" | "expired"; customer: string | null; latest_charge: string | null; last_payment_error: { type: string; code: string; decline_code?: string; message: string } | null; next_action: { type: string; redirect_to_url?: { url: string; return_url: string | null } } | null; metadata: Metadata; created: number; }; export type Price = { id: string; object: "price"; product: string; unit_amount: number; currency: string; type: "one_time" | "recurring"; recurring: { interval: "month" | "year"; interval_count: number } | null; lookup_key: string | null; active: boolean; metadata: Metadata; }; export type Subscription = { id: string; object: "subscription"; status: "incomplete" | "incomplete_expired" | "active" | "past_due" | "canceled"; customer: string; current_period_start: number; current_period_end: number; cancel_at_period_end: boolean; canceled_at: number | null; ended_at: number | null; latest_invoice: string | Invoice | null; items: List<{ id: string; price: Price; quantity: number; current_period_start: number; current_period_end: number }>; metadata: Metadata; }; export type Invoice = { id: string; object: "invoice"; number: string; status: "draft" | "open" | "paid" | "uncollectible" | "void"; customer: string; subscription: string | null; billing_reason: string; amount_due: number; amount_paid: number; total: number; tax: number; currency: string; period_start: number; period_end: number; payment_intent: string | null; hosted_invoice_url: string; }; export type Customer = { id: string; object: "customer"; email: string | null; name: string | null; phone: string | null; metadata: Metadata; created: number }; export type Refund = { id: string; object: "refund"; amount: number; currency: string; payment_intent: string; status: string; reason: string | null }; export type Product = { id: string; object: "product"; name: string; description: string | null; active: boolean; default_price: string | null; metadata: Metadata }; export type VeracityEventType = | "checkout.session.completed" | "checkout.session.expired" | "checkout.session.async_payment_succeeded" | "checkout.session.async_payment_failed" | "payment_intent.created" | "payment_intent.requires_action" | "payment_intent.processing" | "payment_intent.succeeded" | "payment_intent.payment_failed" | "payment_intent.canceled" | "charge.succeeded" | "charge.failed" | "charge.refunded" | "refund.created" | "customer.created" | "customer.updated" | "customer.deleted" | "customer.subscription.created" | "customer.subscription.updated" | "customer.subscription.deleted" | "invoice.created" | "invoice.finalized" | "invoice.paid" | "invoice.payment_succeeded" | "invoice.payment_failed" | "invoice.marked_uncollectible" | "invoice.voided" | "product.created" | "price.created" | (string & {}); export type VeracityEvent> = { id: string; object: "event"; type: VeracityEventType; created: number; livemode: false; data: { object: T; previous_attributes?: Record }; }; export class VeracityPayError extends Error { constructor( message: string, public status: number, public type: string, public code?: string, public declineCode?: string, public param?: string, public raw?: unknown, ) { super(message); this.name = "VeracityPayError"; } } type Body = Record; export function createVeracityPay(opts: VeracityPayOptions) { const base = (opts.baseUrl ?? "http://localhost:3100").replace(/\/$/, ""); const doFetch = opts.fetch ?? fetch; async function request(method: "GET" | "POST" | "DELETE", path: string, body?: Body, ro?: RequestOptions): Promise { const url = new URL(`${base}/v1${path}`); if (method !== "POST" && body) { for (const [k, v] of Object.entries(body)) if (v !== undefined && v !== null) url.searchParams.set(k, String(v)); } for (const e of ro?.expand ?? []) url.searchParams.append("expand[]", e); const headers: Record = { Authorization: `Bearer ${opts.secretKey}` }; if (method === "POST") headers["Content-Type"] = "application/json"; if (ro?.idempotencyKey) headers["Idempotency-Key"] = ro.idempotencyKey; const res = await doFetch(url, { method, headers, body: method === "POST" ? JSON.stringify(body ?? {}) : undefined, cache: "no-store" }); const json = (await res.json().catch(() => ({}))) as { error?: { message: string; type: string; code?: string; decline_code?: string; param?: string } }; if (!res.ok) { const e = json.error ?? { message: `HTTP ${res.status}`, type: "api_error" }; throw new VeracityPayError(e.message, res.status, e.type, e.code, e.decline_code, e.param, json); } return json as T; } const crud = (path: string) => ({ create: (params: Body, ro?: RequestOptions) => request("POST", path, params, ro), retrieve: (id: string, ro?: RequestOptions) => request("GET", `${path}/${id}`, undefined, ro), list: (params?: Body) => request>("GET", path, params), }); return { checkout: { sessions: { ...crud("/checkout/sessions"), expire: (id: string) => request("POST", `/checkout/sessions/${id}/expire`), listLineItems: (id: string) => request>>("GET", `/checkout/sessions/${id}/line_items`), }, }, paymentIntents: { ...crud("/payment_intents"), update: (id: string, params: Body) => request("POST", `/payment_intents/${id}`, params), confirm: (id: string, params: Body = {}) => request("POST", `/payment_intents/${id}/confirm`, params), cancel: (id: string, params: Body = {}) => request("POST", `/payment_intents/${id}/cancel`, params), }, customers: { ...crud("/customers"), update: (id: string, params: Body) => request("POST", `/customers/${id}`, params), del: (id: string) => request<{ id: string; deleted: true }>("DELETE", `/customers/${id}`), }, products: { ...crud("/products"), update: (id: string, params: Body) => request("POST", `/products/${id}`, params) }, prices: { ...crud("/prices"), update: (id: string, params: Body) => request("POST", `/prices/${id}`, params) }, subscriptions: { ...crud("/subscriptions"), update: (id: string, params: Body) => request("POST", `/subscriptions/${id}`, params), /** Cancel immediately. For "at period end", use update(id, { cancel_at_period_end: true }). */ cancel: (id: string) => request("DELETE", `/subscriptions/${id}`), }, invoices: { retrieve: (id: string) => request("GET", `/invoices/${id}`), list: (params?: Body) => request>("GET", "/invoices", params) }, refunds: crud("/refunds"), events: { retrieve: (id: string) => request("GET", `/events/${id}`), list: (params?: Body) => request>("GET", "/events", params) }, webhookEndpoints: { create: (params: { url: string; enabled_events?: string[]; description?: string }) => request<{ id: string; url: string; secret: string }>("POST", "/webhook_endpoints", params), list: () => request>("GET", "/webhook_endpoints"), del: (id: string) => request<{ id: string; deleted: true }>("DELETE", `/webhook_endpoints/${id}`), }, testHelpers: { /** Move the merchant's test clock forward and run renewals. */ advanceClock: (params: { days?: number; months?: number; seconds?: number; frozen_time?: number; renewal_outcome?: "succeed" | "fail" }) => request<{ frozen_time: number }>("POST", "/test_helpers/advance_clock", params), /** Complete an open Checkout Session without the UI. outcome: approve | decline_insufficient_funds | pending_succeed | timeout | … */ completeCheckoutSession: (id: string, params: { outcome?: string; payment_method_type?: string; card_number?: string; email?: string; name?: string; delay?: number } = {}) => request("POST", `/test_helpers/checkout/sessions/${id}/complete`, params), }, }; } export type VeracityPay = ReturnType; /* ---------------------------- webhooks ---------------------------- */ const enc = new TextEncoder(); async function hmacHex(secret: string, payload: string): Promise { const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); const sig = await crypto.subtle.sign("HMAC", key, enc.encode(payload)); return Array.from(new Uint8Array(sig), (b) => b.toString(16).padStart(2, "0")).join(""); } function timingSafeEqualStr(a: string, b: string) { if (a.length !== b.length) return false; let r = 0; for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i); return r === 0; } /** * Verify the `Veracity-Signature: t=,v1=` header and parse the event. * Signed payload = `${t}.${rawBody}`, HMAC-SHA256 with the endpoint's whsec_… secret. * Pass the RAW request body (await req.text()), not re-serialized JSON. */ export async function constructEvent>(rawBody: string, signatureHeader: string | null, secret: string, toleranceSec = 300): Promise> { if (!signatureHeader) throw new VeracityPayError("Missing Veracity-Signature header", 400, "signature_verification_error"); const parts = signatureHeader.split(",").map((p) => p.trim()); const t = Number(parts.find((p) => p.startsWith("t="))?.slice(2)); const sigs = parts.filter((p) => p.startsWith("v1=")).map((p) => p.slice(3)); if (!Number.isFinite(t) || !sigs.length) throw new VeracityPayError("Malformed Veracity-Signature header", 400, "signature_verification_error"); if (Math.abs(Date.now() / 1000 - t) > toleranceSec) throw new VeracityPayError("Timestamp outside the tolerance zone", 400, "signature_verification_error"); const expected = await hmacHex(secret, `${t}.${rawBody}`); if (!sigs.some((s) => timingSafeEqualStr(s, expected))) throw new VeracityPayError("No signatures found matching the expected signature", 400, "signature_verification_error"); return JSON.parse(rawBody) as VeracityEvent; }