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
@@ -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> }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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") ?? ""),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user