diff --git a/packages/payment/README.md b/packages/payment/README.md index 12181c75..de0989ee 100644 --- a/packages/payment/README.md +++ b/packages/payment/README.md @@ -5,3 +5,27 @@ Gateway-neutral, webhook-authoritative payments for WrNexus. Amounts are integer Built-in adapters: offline `sandbox`, Stripe, Razorpay, and PayPal. Applications may add adapters with `defineGateway`. Capabilities are explicit, unsupported operations are refused, signed webhooks are mandatory in production, and package-owned migrations provision the append-only event ledger. Webhook events are deduplicated by the provider's event ID and reduced in provider occurrence/sequence order. Captured and refund states are monotonic, so a delayed retry of an older event cannot regress a terminal payment. + +Configure any number of named gateway accounts in `wrnexus.config.ts`; keep credentials in environment variables: + +```ts +const payment = { + default: "razorpayIndia", + gateways: { + razorpayIndia: { + provider: "razorpay", + keyId: process.env.RAZORPAY_KEY_ID!, + keySecret: process.env.RAZORPAY_KEY_SECRET!, + webhookSecret: process.env.RAZORPAY_WEBHOOK_SECRET ?? "", + }, + stripeGlobal: { + provider: "stripe", + secretKey: process.env.STRIPE_SECRET_KEY!, + publishableKey: process.env.STRIPE_PUBLISHABLE_KEY!, + webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, + }, + }, +} satisfies PaymentConfig; +``` + +At runtime, call `configurePaymentFromConfig(config.payment, databasePaymentStore())` once. Pass a configured gateway name to `initializePayment` to override the default for a specific checkout. diff --git a/packages/payment/src/config.ts b/packages/payment/src/config.ts new file mode 100644 index 00000000..51cc6db6 --- /dev/null +++ b/packages/payment/src/config.ts @@ -0,0 +1,84 @@ +import { + paypalGateway, + razorpayGateway, + sandboxGateway, + stripeGateway, + type PayPalConfig, + type RazorpayConfig, + type SandboxOptions, + type StripeConfig, +} from "./adapters.ts"; +import { configurePayment, type PaymentOptions, type PaymentService } from "./core.ts"; +import type { PaymentGateway, PaymentStore } from "./types.ts"; + +export type ConfiguredPaymentGateway = + | ({ provider: "sandbox" } & SandboxOptions) + | ({ provider: "stripe" } & StripeConfig) + | ({ provider: "razorpay" } & RazorpayConfig) + | ({ provider: "paypal" } & PayPalConfig); + +export interface PaymentConfig { + /** Name of the gateway used when an operation does not select one explicitly. */ + default: string; + /** Any number of named gateway accounts. Names are persisted with payment intents. */ + gateways: Record; + environment?: "development" | "test" | "production"; +} + +function namedGateway(name: string, gateway: PaymentGateway): PaymentGateway { + return Object.freeze({ ...gateway, id: name }); +} + +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"); + + const adapters: PaymentGateway[] = []; + const webhookSecrets: Record = {}; + 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"); + let adapter: PaymentGateway; + switch (value.provider) { + case "sandbox": + adapter = sandboxGateway(value); + break; + case "stripe": + adapter = stripeGateway(value); + break; + case "razorpay": + adapter = razorpayGateway(value); + break; + case "paypal": + adapter = paypalGateway(value); + break; + default: + throw new Error(`WRN-PAYMENT-CONFIG: unsupported provider '${(value as any).provider}'`); + } + adapters.push(namedGateway(name, adapter)); + 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, + default: config.default, + webhookSecrets, + environment: config.environment, + }; +} + +export function configurePaymentFromConfig( + config: PaymentConfig, + store: PaymentStore, +): PaymentService { + return configurePayment(paymentOptionsFromConfig(config, store)); +} diff --git a/packages/payment/src/index.ts b/packages/payment/src/index.ts index 830c5ea4..7ecda8b9 100644 --- a/packages/payment/src/index.ts +++ b/packages/payment/src/index.ts @@ -3,5 +3,6 @@ export * from "./store.ts"; export * from "./database.ts"; export * from "./core.ts"; export * from "./adapters.ts"; +export * from "./config.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 dc35d69d..d04b8af9 100644 --- a/packages/payment/src/plugin.ts +++ b/packages/payment/src/plugin.ts @@ -1,6 +1,7 @@ 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"; const root = dirname(dirname(fileURLToPath(import.meta.url))); const extension = basename(dirname(fileURLToPath(import.meta.url))) === "dist" ? ".js" : ".ts"; @@ -39,17 +40,23 @@ export function paymentPlugin() { : []; }, configure(config, context) { - const value = config.payment as - { default?: string; gateways?: Record; sandbox?: boolean } | undefined; + const value = config.payment as PaymentConfig | undefined; context.metadata.set(key, Boolean(value)); if (!value) return; if (!value.default) throw new Error("WRN-PAYMENT-CONFIG: payment.default is required"); - if (!value.gateways?.[value.default] && value.default !== "sandbox") + const legacySandbox = value.default === "sandbox" && !value.gateways; + if (!value.gateways?.[value.default] && !legacySandbox) throw new Error( `WRN-PAYMENT-CONFIG: default gateway '${value.default}' has no configuration`, ); - if (process.env.NODE_ENV === "production" && value.sandbox) - throw new Error("WRN-PAYMENT-CONFIG: sandbox cannot be enabled in production"); + if ( + process.env.NODE_ENV === "production" && + (legacySandbox || + Object.values(value.gateways ?? {}).some( + (gateway) => gateway && gateway.provider === "sandbox", + )) + ) + throw new Error("WRN-PAYMENT-CONFIG: sandbox gateways cannot be enabled in production"); }, migrations(context: PluginContext) { return context.metadata.get(key) diff --git a/packages/payment/test/config.test.ts b/packages/payment/test/config.test.ts new file mode 100644 index 00000000..f9777456 --- /dev/null +++ b/packages/payment/test/config.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "bun:test"; +import { memoryPaymentStore } from "../src/store.ts"; +import { paymentOptionsFromConfig } from "../src/config.ts"; + +test("configuration creates any number of named provider gateways", () => { + const options = paymentOptionsFromConfig( + { + default: "india", + environment: "test", + gateways: { + india: { + provider: "razorpay", + keyId: "rzp_test", + keySecret: "secret", + webhookSecret: "razorpay-hook", + }, + global: { + provider: "stripe", + secretKey: "sk_test", + publishableKey: "pk_test", + webhookSecret: "stripe-hook", + }, + disabled: false, + }, + }, + memoryPaymentStore(), + ); + + expect(options.default).toBe("india"); + expect(options.adapters.map((adapter) => adapter.id)).toEqual(["india", "global"]); + expect(options.webhookSecrets).toEqual({ india: "razorpay-hook", global: "stripe-hook" }); +}); + +test("configuration rejects a disabled or missing default gateway", () => { + expect(() => + paymentOptionsFromConfig( + { default: "missing", gateways: { sandbox: { provider: "sandbox" } } }, + memoryPaymentStore(), + ), + ).toThrow("default gateway 'missing' is not enabled or configured"); +});