diff --git a/packages/payment/README.md b/packages/payment/README.md
index de0989ee..6c961d02 100644
--- a/packages/payment/README.md
+++ b/packages/payment/README.md
@@ -29,3 +29,56 @@ const payment = {
```
At runtime, call `configurePaymentFromConfig(config.payment, databasePaymentStore())` once. Pass a configured gateway name to `initializePayment` to override the default for a specific checkout.
+
+## Package APIs
+
+When a modern `gateways` configuration is present, the plugin contributes these routes:
+
+- `GET /api/payments/providers` — client-safe names, capabilities, currencies, and countries
+- `GET|POST /api/payments/intents` — list the authenticated subject's payments or initialize one
+- `GET /api/payments/intents/[id]` — status and client-safe checkout configuration
+- `POST /api/payments/intents/[id]/sync`
+- `POST /api/payments/intents/[id]/capture`
+- `POST /api/payments/intents/[id]/cancel`
+- `POST /api/payments/intents/[id]/refund`
+- `GET /api/payments/methods`
+- `POST /api/payments/reconcile`
+- `POST /api/payments/webhook/:gateway`
+
+Provider secrets are held only by server adapters and are never returned by provider, intent, or status APIs. Request-body subject values are ignored. Configure authentication and authorization in the same payment block:
+
+```ts
+payment: {
+ default: "razorpayIndia",
+ gateways: { /* named providers */ },
+ api: {
+ subject: (ctx) => ctx.session?.userId,
+ authorize: async (ctx, action, payment) => {
+ if (["capture", "refund", "reconcile"].includes(action))
+ return await isBillingAdmin(ctx)
+ return !payment || payment.subject === ctx.session?.userId
+ },
+ // Recommended for catalog purchases: derive price and metadata server-side.
+ initialize: async (ctx, body, subject) => resolveCatalogPurchase(body, subject),
+ },
+}
+```
+
+Administrative actions deny by default. Set `api: false` to expose only the signed webhook endpoint. Use CSRF protection and rate limiting in the application's normal middleware.
+
+## Pages, themes, and languages
+
+The package exposes `PaymentOverviewPage`, `PaymentDetailsPage`, `PaymentCheckoutPage`, and `PaymentMethodsPage`, plus the smaller payment components. These use framework theme variables, inherit light/dark mode and accent colors, and collapse to a single-column mobile layout.
+
+All visible copy is supplied through component props. Applications can pass their normal i18n messages without the package forcing a locale or duplicating the application's language switcher:
+
+```wrn
+
+```
+
+Provider-hosted card fields remain on the provider's origin. Applications should never collect or pass raw card numbers, CVVs, or expiry values through these APIs.
diff --git a/packages/payment/components/PaymentCheckoutPage.wrn b/packages/payment/components/PaymentCheckoutPage.wrn
new file mode 100644
index 00000000..d8a810f9
--- /dev/null
+++ b/packages/payment/components/PaymentCheckoutPage.wrn
@@ -0,0 +1,16 @@
+import PaymentSheet from "./PaymentSheet.wrn"
+component PaymentCheckoutPage {
+ props {
+ checkoutUrl: string
+ gateway: string
+ title?: string = "Secure checkout"
+ description?: string = "Complete payment through the selected secure provider."
+ securityLabel?: string = "Your payment credentials are handled by the payment provider."
+ }
+ view {
+
+ }
+}
diff --git a/packages/payment/components/PaymentDetailsPage.wrn b/packages/payment/components/PaymentDetailsPage.wrn
new file mode 100644
index 00000000..be6cd5f6
--- /dev/null
+++ b/packages/payment/components/PaymentDetailsPage.wrn
@@ -0,0 +1,22 @@
+import PaymentStatus from "./PaymentStatus.wrn"
+component PaymentDetailsPage {
+ props {
+ payment: object
+ title?: string = "Payment details"
+ amountLabel?: string = "Amount"
+ providerLabel?: string = "Provider"
+ referenceLabel?: string = "Reference"
+ createdLabel?: string = "Created"
+ }
+ view {
+
+
+
+ - {amountLabel}
- {payment.currency} {payment.amount}
+ {providerLabel}{payment.gateway}
+ {referenceLabel}{payment.id}
+ {createdLabel}{payment.createdAt}
+
+
+ }
+}
diff --git a/packages/payment/components/PaymentMethodsPage.wrn b/packages/payment/components/PaymentMethodsPage.wrn
new file mode 100644
index 00000000..23c29bbf
--- /dev/null
+++ b/packages/payment/components/PaymentMethodsPage.wrn
@@ -0,0 +1,15 @@
+import PaymentMethodList from "./PaymentMethodList.wrn"
+component PaymentMethodsPage {
+ props {
+ methods: array = []
+ title?: string = "Payment methods"
+ description?: string = "Manage provider-vaulted payment methods."
+ emptyLabel?: string = "No saved payment methods."
+ }
+ view {
+
+
+ {#if methods.length}
{:else}
{emptyLabel}
{/if}
+
+ }
+}
diff --git a/packages/payment/components/PaymentOverviewPage.wrn b/packages/payment/components/PaymentOverviewPage.wrn
new file mode 100644
index 00000000..592bbfbb
--- /dev/null
+++ b/packages/payment/components/PaymentOverviewPage.wrn
@@ -0,0 +1,21 @@
+import PaymentHistory from "./PaymentHistory.wrn"
+import PaymentSummary from "./PaymentSummary.wrn"
+component PaymentOverviewPage {
+ props {
+ payments: array = []
+ captured: number = 0
+ refunded: number = 0
+ net: number = 0
+ currency: string = "USD"
+ title?: string = "Payments"
+ description?: string = "Review payment activity, statuses, and totals."
+ emptyLabel?: string = "No payments yet."
+ }
+ view {
+
+
+
+ {title}
{#if payments.length}
{:else}
{emptyLabel}
{/if}
+
+ }
+}
diff --git a/packages/payment/components/PaymentSummary.wrn b/packages/payment/components/PaymentSummary.wrn
index 006df451..9d49ab89 100644
--- a/packages/payment/components/PaymentSummary.wrn
+++ b/packages/payment/components/PaymentSummary.wrn
@@ -5,5 +5,5 @@ component PaymentSummary {
net: number = 0
currency: string
}
- view { - Captured
- {currency} {captured}
- Refunded
- {currency} {refunded}
- Net
- {currency} {net}
}
+ view { - Captured
- {currency} {captured}
- Refunded
- {currency} {refunded}
- Net
- {currency} {net}
}
}
diff --git a/packages/payment/components/payment.css b/packages/payment/components/payment.css
new file mode 100644
index 00000000..6f0db43c
--- /dev/null
+++ b/packages/payment/components/payment.css
@@ -0,0 +1,154 @@
+.wrn-payment-page {
+ color: var(--color-text, var(--wrn-color-text, #f5f7f6));
+ display: grid;
+ gap: 1.25rem;
+ width: 100%;
+}
+.wrn-payment-page__header {
+ align-items: flex-start;
+ display: flex;
+ gap: 1rem;
+ justify-content: space-between;
+}
+.wrn-payment-page__header h1 {
+ font-size: clamp(1.75rem, 4vw, 2.75rem);
+ letter-spacing: -0.04em;
+ margin: 0.35rem 0;
+}
+.wrn-payment-page__header p {
+ color: var(--color-text-muted, var(--wrn-color-muted, #a7afab));
+ margin: 0;
+ max-width: 42rem;
+}
+.wrn-payment-page__eyebrow {
+ color: var(--color-primary, var(--wrn-color-primary, #10b981));
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
+.wrn-payment-panel,
+.wrn-payment-details,
+.wrn-payment-summary {
+ background: var(--color-surface, var(--wrn-color-surface, #111412));
+ border: 1px solid var(--color-border, var(--wrn-color-border, #29302c));
+ border-radius: var(--radius-lg, 1rem);
+ padding: 1.25rem;
+}
+.wrn-payment-summary {
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+.wrn-payment-summary div,
+.wrn-payment-details div {
+ display: grid;
+ gap: 0.35rem;
+}
+.wrn-payment-summary dt,
+.wrn-payment-details dt {
+ color: var(--color-text-muted, var(--wrn-color-muted, #a7afab));
+ font-size: 0.75rem;
+ text-transform: uppercase;
+}
+.wrn-payment-summary dd,
+.wrn-payment-details dd {
+ font-size: 1.1rem;
+ font-weight: 700;
+ margin: 0;
+}
+.wrn-payment-details {
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ margin: 0;
+}
+.wrn-payment-history,
+.wrn-payment-methods {
+ display: grid;
+ gap: 0.25rem;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+.wrn-payment-history li,
+.wrn-payment-methods li {
+ align-items: center;
+ border-bottom: 1px solid var(--color-border, var(--wrn-color-border, #29302c));
+ display: flex;
+ justify-content: space-between;
+ padding: 0.85rem 0;
+}
+.wrn-payment-history li:last-child,
+.wrn-payment-methods li:last-child {
+ border-bottom: 0;
+}
+.wrn-payment-status {
+ align-items: center;
+ background: color-mix(
+ in srgb,
+ var(--color-primary, var(--wrn-color-primary, #10b981)) 12%,
+ transparent
+ );
+ border: 1px solid
+ color-mix(in srgb, var(--color-primary, var(--wrn-color-primary, #10b981)) 32%, transparent);
+ border-radius: 999px;
+ display: inline-flex;
+ gap: 0.5rem;
+ padding: 0.45rem 0.75rem;
+}
+.wrn-payment-status strong {
+ font-size: 0.72rem;
+}
+.wrn-payment-status span {
+ text-transform: capitalize;
+}
+.wrn-payment-sheet {
+ display: grid;
+ gap: 1rem;
+}
+.wrn-payment-sheet a,
+.wrn-payment-button {
+ align-items: center;
+ background: var(--color-primary, var(--wrn-color-primary, #10b981));
+ border-radius: var(--radius-md, 0.7rem);
+ color: var(--color-on-primary, #fff);
+ display: inline-flex;
+ font-weight: 700;
+ justify-content: center;
+ min-height: 2.75rem;
+ padding: 0.65rem 1rem;
+ text-decoration: none;
+}
+.wrn-payment-button[aria-disabled="true"] {
+ cursor: not-allowed;
+ opacity: 0.5;
+ pointer-events: none;
+}
+.wrn-payment-empty {
+ color: var(--color-text-muted, var(--wrn-color-muted, #a7afab));
+ padding: 2rem;
+ text-align: center;
+}
+.wrn-payment-security {
+ color: var(--color-text-muted, var(--wrn-color-muted, #a7afab));
+ font-size: 0.8rem;
+}
+.wrn-payment-panel h2 {
+ font-size: 1rem;
+ margin: 0 0 0.75rem;
+}
+@media (max-width: 640px) {
+ .wrn-payment-page__header {
+ display: grid;
+ }
+ .wrn-payment-summary,
+ .wrn-payment-details {
+ grid-template-columns: 1fr;
+ }
+ .wrn-payment-panel,
+ .wrn-payment-details,
+ .wrn-payment-summary {
+ padding: 1rem;
+ }
+}
diff --git a/packages/payment/package.json b/packages/payment/package.json
index 38ec7418..9ea5b790 100644
--- a/packages/payment/package.json
+++ b/packages/payment/package.json
@@ -13,6 +13,7 @@
".": "./src/index.ts",
"./adapters": "./src/adapters.ts",
"./plugin": "./src/plugin.ts",
+ "./runtime-api": "./src/runtime-api.ts",
"./runtime-webhook": "./src/runtime-webhook.ts"
},
"dependencies": {
diff --git a/packages/payment/src/config.ts b/packages/payment/src/config.ts
index 51cc6db6..b60f43b4 100644
--- a/packages/payment/src/config.ts
+++ b/packages/payment/src/config.ts
@@ -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;
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) {
+ 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 = {};
@@ -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,
diff --git a/packages/payment/src/core.ts b/packages/payment/src/core.ts
index 2e4fdf7a..2c15a8ea 100644
--- a/packages/payment/src/core.ts
+++ b/packages/payment/src/core.ts
@@ -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,
+ })),
};
}
diff --git a/packages/payment/src/http.ts b/packages/payment/src/http.ts
new file mode 100644
index 00000000..d492905c
--- /dev/null
+++ b/packages/payment/src/http.ts
@@ -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;
+ 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;
+ /** Additional authorization. Administrative actions deny by default. */
+ authorize?(
+ context: PaymentApiContext,
+ action: PaymentApiAction,
+ payment?: PaymentIntent,
+ ): boolean | Promise;
+ /** Replace client input with authoritative catalog/order data before creating an intent. */
+ initialize?(
+ context: PaymentApiContext,
+ body: Record,
+ subject: string,
+ ): Omit | Promise>;
+}
+
+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 };
+}
diff --git a/packages/payment/src/index.ts b/packages/payment/src/index.ts
index 7ecda8b9..8696ba4d 100644
--- a/packages/payment/src/index.ts
+++ b/packages/payment/src/index.ts
@@ -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";
diff --git a/packages/payment/src/plugin.ts b/packages/payment/src/plugin.ts
index d04b8af9..ea55d0fb 100644
--- a/packages/payment/src/plugin.ts
+++ b/packages/payment/src/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)
diff --git a/packages/payment/src/runtime-api.ts b/packages/payment/src/runtime-api.ts
new file mode 100644
index 00000000..35a71d96
--- /dev/null
+++ b/packages/payment/src/runtime-api.ts
@@ -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;
+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)
+ : 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") ?? ""),
+ }),
+ );
+}
diff --git a/packages/payment/test/runtime-api.test.ts b/packages/payment/test/runtime-api.test.ts
new file mode 100644
index 00000000..e151f97e
--- /dev/null
+++ b/packages/payment/test/runtime-api.test.ts
@@ -0,0 +1,70 @@
+import { expect, test } from "bun:test";
+import { configurePaymentFromConfig } from "../src/config.ts";
+import { configurePaymentApi } from "../src/http.ts";
+import { GET, POST } from "../src/runtime-api.ts";
+import { memoryPaymentStore } from "../src/store.ts";
+
+function request(path: string, body?: unknown) {
+ return new Request(`https://app.test${path}`, {
+ method: body ? "POST" : "GET",
+ headers: body ? { "content-type": "application/json" } : undefined,
+ body: body ? JSON.stringify(body) : undefined,
+ });
+}
+
+test("package APIs use server subjects and never expose gateway secrets", async () => {
+ configurePaymentFromConfig(
+ {
+ default: "testGateway",
+ environment: "test",
+ gateways: { testGateway: { provider: "sandbox", secret: "server-only" } },
+ },
+ memoryPaymentStore(),
+ );
+ configurePaymentApi({ subject: (ctx) => ctx.user?.id });
+
+ const providers = await GET({ req: request("/api/payments/providers") });
+ const providerBody = await providers.text();
+ expect(providers.status).toBe(200);
+ expect(providerBody).toContain("testGateway");
+ expect(providerBody).not.toContain("server-only");
+
+ const denied = await POST({
+ req: request("/api/payments/intents", {
+ subject: "attacker-selected",
+ amount: 500,
+ currency: "USD",
+ idempotencyKey: "anonymous",
+ }),
+ });
+ expect(denied.status).toBe(403);
+
+ const created = await POST({
+ req: request("/api/payments/intents", {
+ subject: "attacker-selected",
+ amount: 500,
+ currency: "USD",
+ idempotencyKey: "owned",
+ }),
+ user: { id: "user-1" },
+ });
+ expect(created.status).toBe(201);
+ const payment = await created.json();
+ expect(payment.id).toStartWith("pay_");
+ expect(JSON.stringify(payment)).not.toContain("server-only");
+
+ const listed = await GET({
+ req: request("/api/payments/intents"),
+ user: { id: "user-1" },
+ });
+ expect((await listed.json()).payments).toHaveLength(1);
+});
+
+test("administrative payment APIs deny by default", async () => {
+ configurePaymentApi({ subject: () => "user-1" });
+ const response = await POST({
+ req: request("/api/payments/reconcile", {}),
+ user: { id: "user-1" },
+ });
+ expect(response.status).toBe(403);
+});