feat(payment): expose secure APIs and themed pages
This commit is contained in:
1 parent
dd8d6ecc3b
commit
2d77f5457c
15 files changed
+654
-13
No files matched your search
@@ -10,6 +10,7 @@ import {
|
||||
} from "./adapters.ts";
|
||||
import { configurePayment, type PaymentOptions, type PaymentService } from "./core.ts";
|
||||
import type { PaymentGateway, PaymentStore } from "./types.ts";
|
||||
import type { PaymentApiConfig } from "./http.ts";
|
||||
|
||||
export type ConfiguredPaymentGateway =
|
||||
| ({ provider: "sandbox" } & SandboxOptions)
|
||||
@@ -23,6 +24,43 @@ export interface PaymentConfig {
|
||||
/** Any number of named gateway accounts. Names are persisted with payment intents. */
|
||||
gateways: Record<string, ConfiguredPaymentGateway | false>;
|
||||
environment?: "development" | "test" | "production";
|
||||
api?: PaymentApiConfig | false;
|
||||
/** Defaults to the package database store. Override for custom persistence or tests. */
|
||||
store?: PaymentStore;
|
||||
}
|
||||
|
||||
function required(name: string, fields: Record<string, unknown>) {
|
||||
for (const [field, value] of Object.entries(fields))
|
||||
if (typeof value !== "string" || !value.trim())
|
||||
throw new Error(`WRN-PAYMENT-CONFIG: gateway '${name}' requires ${field}`);
|
||||
}
|
||||
|
||||
export function validatePaymentConfig(config: PaymentConfig) {
|
||||
if (!config?.default?.trim()) throw new Error("WRN-PAYMENT-CONFIG: payment.default is required");
|
||||
if (!config.gateways || typeof config.gateways !== "object")
|
||||
throw new Error("WRN-PAYMENT-CONFIG: payment.gateways is required");
|
||||
for (const [name, value] of Object.entries(config.gateways)) {
|
||||
if (value === false) continue;
|
||||
if (!name.trim()) throw new Error("WRN-PAYMENT-CONFIG: gateway name is required");
|
||||
if (value.provider === "stripe")
|
||||
required(name, {
|
||||
secretKey: value.secretKey,
|
||||
publishableKey: value.publishableKey,
|
||||
webhookSecret: value.webhookSecret,
|
||||
});
|
||||
if (value.provider === "razorpay")
|
||||
required(name, { keyId: value.keyId, keySecret: value.keySecret });
|
||||
if (value.provider === "paypal")
|
||||
required(name, {
|
||||
clientId: value.clientId,
|
||||
clientSecret: value.clientSecret,
|
||||
webhookId: value.webhookId,
|
||||
});
|
||||
}
|
||||
if (!config.gateways[config.default])
|
||||
throw new Error(
|
||||
`WRN-PAYMENT-CONFIG: default gateway '${config.default}' is not enabled or configured`,
|
||||
);
|
||||
}
|
||||
|
||||
function namedGateway(name: string, gateway: PaymentGateway): PaymentGateway {
|
||||
@@ -33,9 +71,7 @@ export function paymentOptionsFromConfig(
|
||||
config: PaymentConfig,
|
||||
store: PaymentStore,
|
||||
): PaymentOptions {
|
||||
if (!config?.default?.trim()) throw new Error("WRN-PAYMENT-CONFIG: payment.default is required");
|
||||
if (!config.gateways || typeof config.gateways !== "object")
|
||||
throw new Error("WRN-PAYMENT-CONFIG: payment.gateways is required");
|
||||
validatePaymentConfig(config);
|
||||
|
||||
const adapters: PaymentGateway[] = [];
|
||||
const webhookSecrets: Record<string, string> = {};
|
||||
@@ -63,10 +99,6 @@ export function paymentOptionsFromConfig(
|
||||
if ("webhookSecret" in value && value.webhookSecret) webhookSecrets[name] = value.webhookSecret;
|
||||
if (value.provider === "paypal" && value.webhookId) webhookSecrets[name] = value.webhookId;
|
||||
}
|
||||
if (!adapters.some((adapter) => adapter.id === config.default))
|
||||
throw new Error(
|
||||
`WRN-PAYMENT-CONFIG: default gateway '${config.default}' is not enabled or configured`,
|
||||
);
|
||||
return {
|
||||
store,
|
||||
adapters,
|
||||
|
||||
@@ -416,6 +416,12 @@ export function definePayment(options: PaymentOptions) {
|
||||
reconcilePayments,
|
||||
paymentWebhookHandler,
|
||||
capabilitiesOf: (id: string) => adapterFor(id)?.capabilities,
|
||||
providers: () =>
|
||||
[...adapters.values()].map((adapter) => ({
|
||||
id: adapter.id,
|
||||
capabilities: adapter.capabilities,
|
||||
supports: adapter.supports,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { InitializePaymentInput, PaymentService } from "./core.ts";
|
||||
import type { PaymentIntent } from "./types.ts";
|
||||
|
||||
export type PaymentApiAction =
|
||||
| "list"
|
||||
| "read"
|
||||
| "initialize"
|
||||
| "sync"
|
||||
| "cancel"
|
||||
| "capture"
|
||||
| "refund"
|
||||
| "methods"
|
||||
| "reconcile";
|
||||
|
||||
export interface PaymentApiContext {
|
||||
req: Request;
|
||||
params?: Record<string, string>;
|
||||
user?: { id?: string };
|
||||
session?: { userId?: string; subject?: string };
|
||||
}
|
||||
|
||||
export interface PaymentApiConfig {
|
||||
enabled?: boolean;
|
||||
/** Resolve the authenticated subject on the server. Request-body subject values are ignored. */
|
||||
subject?(context: PaymentApiContext): string | undefined | Promise<string | undefined>;
|
||||
/** Additional authorization. Administrative actions deny by default. */
|
||||
authorize?(
|
||||
context: PaymentApiContext,
|
||||
action: PaymentApiAction,
|
||||
payment?: PaymentIntent,
|
||||
): boolean | Promise<boolean>;
|
||||
/** Replace client input with authoritative catalog/order data before creating an intent. */
|
||||
initialize?(
|
||||
context: PaymentApiContext,
|
||||
body: Record<string, unknown>,
|
||||
subject: string,
|
||||
): Omit<InitializePaymentInput, "subject"> | Promise<Omit<InitializePaymentInput, "subject">>;
|
||||
}
|
||||
|
||||
const HTTP = Symbol.for("@wrnexus/payment:http:v1");
|
||||
type PaymentHttpGlobal = typeof globalThis & { [HTTP]?: PaymentApiConfig };
|
||||
|
||||
export function configurePaymentApi(config: PaymentApiConfig = {}) {
|
||||
(globalThis as PaymentHttpGlobal)[HTTP] = config;
|
||||
}
|
||||
|
||||
export function getPaymentApiConfig(): PaymentApiConfig {
|
||||
return (globalThis as PaymentHttpGlobal)[HTTP] ?? {};
|
||||
}
|
||||
|
||||
export async function paymentApiSubject(context: PaymentApiContext) {
|
||||
const config = getPaymentApiConfig();
|
||||
const resolved = await config.subject?.(context);
|
||||
return resolved?.trim() || context.user?.id?.trim() || context.session?.userId?.trim() || "";
|
||||
}
|
||||
|
||||
export async function paymentApiAllowed(
|
||||
context: PaymentApiContext,
|
||||
action: PaymentApiAction,
|
||||
payment?: PaymentIntent,
|
||||
) {
|
||||
const config = getPaymentApiConfig();
|
||||
if (config.enabled === false) return false;
|
||||
if (config.authorize) return Boolean(await config.authorize(context, action, payment));
|
||||
const subject = await paymentApiSubject(context);
|
||||
if (!subject) return false;
|
||||
if (["capture", "refund", "reconcile"].includes(action)) return false;
|
||||
return !payment || payment.subject === subject;
|
||||
}
|
||||
|
||||
export function publicPayment(payment: PaymentIntent, status: string) {
|
||||
return {
|
||||
id: payment.id,
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
gateway: payment.gateway,
|
||||
status,
|
||||
createdAt: payment.createdAt,
|
||||
checkoutUrl: payment.checkoutUrl,
|
||||
client: payment.client,
|
||||
metadata: payment.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureOwnedPayment(
|
||||
service: PaymentService,
|
||||
context: PaymentApiContext,
|
||||
id: string,
|
||||
action: PaymentApiAction,
|
||||
) {
|
||||
const payment = await service.getPayment(id);
|
||||
if (!payment) return { response: Response.json({ error: "payment not found" }, { status: 404 }) };
|
||||
if (!(await paymentApiAllowed(context, action, payment)))
|
||||
return { response: Response.json({ error: "payment access denied" }, { status: 403 }) };
|
||||
return { payment };
|
||||
}
|
||||
@@ -4,5 +4,6 @@ export * from "./database.ts";
|
||||
export * from "./core.ts";
|
||||
export * from "./adapters.ts";
|
||||
export * from "./config.ts";
|
||||
export * from "./http.ts";
|
||||
export * from "./contract.ts";
|
||||
export { paymentPlugin } from "./plugin.ts";
|
||||
@@ -1,11 +1,25 @@
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { definePlugin, type PluginContext } from "@wrnexus/plugin";
|
||||
import type { PaymentConfig } from "./config.ts";
|
||||
import { configurePaymentFromConfig, validatePaymentConfig, type PaymentConfig } from "./config.ts";
|
||||
import { configurePaymentApi } from "./http.ts";
|
||||
import { databasePaymentStore } from "./database.ts";
|
||||
|
||||
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const extension = basename(dirname(fileURLToPath(import.meta.url))) === "dist" ? ".js" : ".ts";
|
||||
const webhookEntry = join(dirname(fileURLToPath(import.meta.url)), `runtime-webhook${extension}`);
|
||||
const apiEntry = join(dirname(fileURLToPath(import.meta.url)), `runtime-api${extension}`);
|
||||
const apiRoutes = [
|
||||
"/api/payments/providers",
|
||||
"/api/payments/intents",
|
||||
"/api/payments/intents/[id]",
|
||||
"/api/payments/intents/[id]/sync",
|
||||
"/api/payments/intents/[id]/capture",
|
||||
"/api/payments/intents/[id]/cancel",
|
||||
"/api/payments/intents/[id]/refund",
|
||||
"/api/payments/methods",
|
||||
"/api/payments/reconcile",
|
||||
];
|
||||
const migration = `-- +up
|
||||
CREATE TABLE IF NOT EXISTS wrn_payment_intent (id TEXT PRIMARY KEY, subject TEXT NOT NULL, gateway TEXT NOT NULL, gateway_ref TEXT NOT NULL, amount BIGINT NOT NULL, currency TEXT NOT NULL, idempotency_key TEXT NOT NULL UNIQUE, created_at BIGINT NOT NULL, checkout_url TEXT, client_json TEXT NOT NULL, metadata_json TEXT NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS wrn_payment_intent_subject ON wrn_payment_intent(subject, created_at);
|
||||
@@ -32,17 +46,40 @@ export function paymentPlugin() {
|
||||
const key = "@wrnexus/payment:enabled";
|
||||
return definePlugin({
|
||||
name: "@wrnexus/payment",
|
||||
version: "0.8.1",
|
||||
version: "0.8.5",
|
||||
componentDirs: [join(root, "components")],
|
||||
styleSources: [
|
||||
{
|
||||
id: "payment-components",
|
||||
entry: join(root, "components", "payment.css"),
|
||||
source: join(root, "components"),
|
||||
order: "normal",
|
||||
},
|
||||
],
|
||||
routeEntries(context) {
|
||||
return context.metadata.get(key)
|
||||
? [{ kind: "api" as const, path: "/api/payments/webhook/:gateway", entry: webhookEntry }]
|
||||
: [];
|
||||
if (!context.metadata.get(key)) return [];
|
||||
const apiEnabled = context.metadata.get("@wrnexus/payment:api") !== false;
|
||||
return [
|
||||
{ kind: "api" as const, path: "/api/payments/webhook/:gateway", entry: webhookEntry },
|
||||
...(apiEnabled
|
||||
? apiRoutes.map((path) => ({ kind: "api" as const, path, entry: apiEntry }))
|
||||
: []),
|
||||
];
|
||||
},
|
||||
configure(config, context) {
|
||||
const value = config.payment as PaymentConfig | undefined;
|
||||
context.metadata.set(key, Boolean(value));
|
||||
if (!value) return;
|
||||
const api = value.api === false ? { enabled: false } : (value.api ?? {});
|
||||
context.metadata.set(
|
||||
"@wrnexus/payment:api",
|
||||
Boolean(value.gateways) && api.enabled !== false,
|
||||
);
|
||||
configurePaymentApi(api);
|
||||
if (value.gateways) {
|
||||
validatePaymentConfig(value);
|
||||
configurePaymentFromConfig(value, value.store ?? databasePaymentStore());
|
||||
}
|
||||
if (!value.default) throw new Error("WRN-PAYMENT-CONFIG: payment.default is required");
|
||||
const legacySandbox = value.default === "sandbox" && !value.gateways;
|
||||
if (!value.gateways?.[value.default] && !legacySandbox)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { getPaymentService } from "./core.ts";
|
||||
import {
|
||||
ensureOwnedPayment,
|
||||
getPaymentApiConfig,
|
||||
paymentApiAllowed,
|
||||
paymentApiSubject,
|
||||
publicPayment,
|
||||
type PaymentApiContext,
|
||||
} from "./http.ts";
|
||||
|
||||
const json = (value: unknown, status = 200) => Response.json(value, { status });
|
||||
const bodyOf = async (req: Request) =>
|
||||
(await req.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const idOf = (ctx: PaymentApiContext) => ctx.params?.id?.trim() ?? "";
|
||||
const resultResponse = (result: any, success = 200) =>
|
||||
result.ok
|
||||
? json(result.value, success)
|
||||
: json({ error: result.reason, fault: result.fault }, 422);
|
||||
|
||||
export async function GET(ctx: PaymentApiContext) {
|
||||
const service = getPaymentService();
|
||||
const path = new URL(ctx.req.url).pathname;
|
||||
if (path.endsWith("/providers")) {
|
||||
if (getPaymentApiConfig().enabled === false)
|
||||
return json({ error: "payment API disabled" }, 404);
|
||||
return json({ providers: service.providers() });
|
||||
}
|
||||
if (path.endsWith("/methods")) {
|
||||
if (!(await paymentApiAllowed(ctx, "methods")))
|
||||
return json({ error: "payment access denied" }, 403);
|
||||
return json({ methods: await service.paymentMethods(await paymentApiSubject(ctx)) });
|
||||
}
|
||||
if (idOf(ctx)) {
|
||||
const owned = await ensureOwnedPayment(service, ctx, idOf(ctx), "read");
|
||||
if (owned.response) return owned.response;
|
||||
return json(publicPayment(owned.payment!, await service.checkPayment(owned.payment!.id)));
|
||||
}
|
||||
if (!(await paymentApiAllowed(ctx, "list"))) return json({ error: "payment access denied" }, 403);
|
||||
const subject = await paymentApiSubject(ctx);
|
||||
const rows = await service.listPayments({ subject });
|
||||
return json({
|
||||
payments: await Promise.all(
|
||||
rows.map(async (payment) => publicPayment(payment, await service.checkPayment(payment.id))),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(ctx: PaymentApiContext) {
|
||||
const service = getPaymentService();
|
||||
const path = new URL(ctx.req.url).pathname;
|
||||
const body = await bodyOf(ctx.req);
|
||||
if (path.endsWith("/intents")) {
|
||||
if (!(await paymentApiAllowed(ctx, "initialize")))
|
||||
return json({ error: "payment access denied" }, 403);
|
||||
const subject = await paymentApiSubject(ctx);
|
||||
const resolver = getPaymentApiConfig().initialize;
|
||||
const input = resolver
|
||||
? await resolver(ctx, body, subject)
|
||||
: {
|
||||
amount: Number(body.amount),
|
||||
currency: String(body.currency ?? "").toUpperCase(),
|
||||
gateway: typeof body.gateway === "string" ? body.gateway : undefined,
|
||||
country: typeof body.country === "string" ? body.country : undefined,
|
||||
capture: body.capture === "manual" ? ("manual" as const) : ("automatic" as const),
|
||||
idempotencyKey: String(
|
||||
body.idempotencyKey ?? ctx.req.headers.get("idempotency-key") ?? "",
|
||||
),
|
||||
returnUrl: typeof body.returnUrl === "string" ? body.returnUrl : undefined,
|
||||
metadata:
|
||||
body.metadata && typeof body.metadata === "object"
|
||||
? (body.metadata as Record<string, string>)
|
||||
: undefined,
|
||||
};
|
||||
const result = await service.initializePayment({ ...input, subject });
|
||||
if (!result.ok) return resultResponse(result, 201);
|
||||
return json(publicPayment(result.value, await service.checkPayment(result.value.id)), 201);
|
||||
}
|
||||
if (path.endsWith("/reconcile")) {
|
||||
if (!(await paymentApiAllowed(ctx, "reconcile")))
|
||||
return json({ error: "payment access denied" }, 403);
|
||||
return json({ discrepancies: await service.reconcilePayments() });
|
||||
}
|
||||
const id = idOf(ctx);
|
||||
if (!id) return json({ error: "payment id is required" }, 400);
|
||||
const action = path.endsWith("/sync")
|
||||
? "sync"
|
||||
: path.endsWith("/capture")
|
||||
? "capture"
|
||||
: path.endsWith("/cancel")
|
||||
? "cancel"
|
||||
: path.endsWith("/refund")
|
||||
? "refund"
|
||||
: undefined;
|
||||
if (!action) return json({ error: "payment operation not found" }, 404);
|
||||
const owned = await ensureOwnedPayment(service, ctx, id, action);
|
||||
if (owned.response) return owned.response;
|
||||
if (action === "sync") return resultResponse(await service.syncPayment(id));
|
||||
if (action === "capture")
|
||||
return resultResponse(
|
||||
await service.capturePayment(
|
||||
id,
|
||||
body.amount === undefined
|
||||
? undefined
|
||||
: { amount: Number(body.amount), currency: owned.payment!.currency },
|
||||
),
|
||||
);
|
||||
if (action === "cancel")
|
||||
return resultResponse(await service.cancelPayment(id, String(body.reason ?? "")));
|
||||
return resultResponse(
|
||||
await service.refundPayment({
|
||||
id,
|
||||
amount: body.amount === undefined ? undefined : Number(body.amount),
|
||||
reason: String(body.reason ?? ""),
|
||||
idempotencyKey: String(body.idempotencyKey ?? ctx.req.headers.get("idempotency-key") ?? ""),
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user