feat(payment): expose secure APIs and themed pages

This commit is contained in:
Clintchiz committed 2026-08-28 09:59:07 +05:30
1 parent dd8d6ecc3b
commit 2d77f5457c
15 files changed
+654 -13

No files matched your search

+53
View File
@@ -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
<PaymentMethodsPage
methods={payments.methods}
title={t.paymentMethodsTitle}
description={t.paymentMethodsDescription}
emptyLabel={t.noPaymentMethods}
/>
```
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.
@@ -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 {
<section class="wrn-payment-page wrn-payment-checkout-page">
<header class="wrn-payment-page__header"><div><span class="wrn-payment-page__eyebrow">{gateway}</span><h1>{title}</h1><p>{description}</p></div></header>
<div class="wrn-payment-panel"><PaymentSheet checkoutUrl={checkoutUrl} gateway={gateway} /><p class="wrn-payment-security">{securityLabel}</p></div>
</section>
}
}
@@ -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 {
<section class="wrn-payment-page">
<header class="wrn-payment-page__header"><div><span class="wrn-payment-page__eyebrow">{title}</span><h1>{title}</h1></div><PaymentStatus status={payment.status} /></header>
<dl class="wrn-payment-details">
<div><dt>{amountLabel}</dt><dd>{payment.currency} {payment.amount}</dd></div>
<div><dt>{providerLabel}</dt><dd>{payment.gateway}</dd></div>
<div><dt>{referenceLabel}</dt><dd>{payment.id}</dd></div>
<div><dt>{createdLabel}</dt><dd>{payment.createdAt}</dd></div>
</dl>
</section>
}
}
@@ -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 {
<section class="wrn-payment-page">
<header class="wrn-payment-page__header"><div><span class="wrn-payment-page__eyebrow">{title}</span><h1>{title}</h1><p>{description}</p></div></header>
<div class="wrn-payment-panel">{#if methods.length}<PaymentMethodList methods={methods} />{:else}<div class="wrn-payment-empty">{emptyLabel}</div>{/if}</div>
</section>
}
}
@@ -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 {
<section class="wrn-payment-page">
<header class="wrn-payment-page__header"><div><span class="wrn-payment-page__eyebrow">{title}</span><h1>{title}</h1><p>{description}</p></div></header>
<PaymentSummary captured={captured} refunded={refunded} net={net} currency={currency} />
<div class="wrn-payment-panel"><h2>{title}</h2>{#if payments.length}<PaymentHistory payments={payments} />{:else}<div class="wrn-payment-empty">{emptyLabel}</div>{/if}</div>
</section>
}
}
@@ -5,5 +5,5 @@ component PaymentSummary {
net: number = 0
currency: string
}
view { <dl><dt>Captured</dt><dd>{currency} {captured}</dd><dt>Refunded</dt><dd>{currency} {refunded}</dd><dt>Net</dt><dd>{currency} {net}</dd></dl> }
view { <dl class="wrn-payment-summary"><div><dt>Captured</dt><dd>{currency} {captured}</dd></div><div><dt>Refunded</dt><dd>{currency} {refunded}</dd></div><div><dt>Net</dt><dd>{currency} {net}</dd></div></dl> }
}
+154
View File
@@ -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;
}
}
+1
View File
@@ -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": {
+39 -7
View File
@@ -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,
+6
View File
@@ -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,
})),
};
}
+96
View File
@@ -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 };
}
+1
View File
@@ -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";
+42 -5
View File
@@ -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)
+117
View File
@@ -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") ?? ""),
}),
);
}
+70
View File
@@ -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);
});