From 2d77f5457cf655a6f9b3b29585f4f4ccdb037211 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Fri, 28 Aug 2026 09:59:07 +0530 Subject: [PATCH] feat(payment): expose secure APIs and themed pages --- packages/payment/README.md | 53 ++++++ .../components/PaymentCheckoutPage.wrn | 16 ++ .../payment/components/PaymentDetailsPage.wrn | 22 +++ .../payment/components/PaymentMethodsPage.wrn | 15 ++ .../components/PaymentOverviewPage.wrn | 21 +++ .../payment/components/PaymentSummary.wrn | 2 +- packages/payment/components/payment.css | 154 ++++++++++++++++++ packages/payment/package.json | 1 + packages/payment/src/config.ts | 46 +++++- packages/payment/src/core.ts | 6 + packages/payment/src/http.ts | 96 +++++++++++ packages/payment/src/index.ts | 1 + packages/payment/src/plugin.ts | 47 +++++- packages/payment/src/runtime-api.ts | 117 +++++++++++++ packages/payment/test/runtime-api.test.ts | 70 ++++++++ 15 files changed, 654 insertions(+), 13 deletions(-) create mode 100644 packages/payment/components/PaymentCheckoutPage.wrn create mode 100644 packages/payment/components/PaymentDetailsPage.wrn create mode 100644 packages/payment/components/PaymentMethodsPage.wrn create mode 100644 packages/payment/components/PaymentOverviewPage.wrn create mode 100644 packages/payment/components/payment.css create mode 100644 packages/payment/src/http.ts create mode 100644 packages/payment/src/runtime-api.ts create mode 100644 packages/payment/test/runtime-api.test.ts 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 { +
+
{gateway}

{title}

{description}

+

{securityLabel}

+
+ } +} 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 { +
+
{title}

{title}

+
+
{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 { +
+
{title}

{title}

{description}

+
{#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}

{title}

{description}

+ +

{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); +});