feat(payment): configure named gateway providers
This commit is contained in:
1 parent
c578bc0f92
commit
95c291db64
5 files changed
+162
-5
No files matched your search
@@ -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.
|
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.
|
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.
|
||||||
@@ -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<string, ConfiguredPaymentGateway | false>;
|
||||||
|
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<string, string> = {};
|
||||||
|
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));
|
||||||
|
}
|
||||||
@@ -3,5 +3,6 @@ export * from "./store.ts";
|
|||||||
export * from "./database.ts";
|
export * from "./database.ts";
|
||||||
export * from "./core.ts";
|
export * from "./core.ts";
|
||||||
export * from "./adapters.ts";
|
export * from "./adapters.ts";
|
||||||
|
export * from "./config.ts";
|
||||||
export * from "./contract.ts";
|
export * from "./contract.ts";
|
||||||
export { paymentPlugin } from "./plugin.ts";
|
export { paymentPlugin } from "./plugin.ts";
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { basename, dirname, join } from "node:path";
|
import { basename, dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { definePlugin, type PluginContext } from "@wrnexus/plugin";
|
import { definePlugin, type PluginContext } from "@wrnexus/plugin";
|
||||||
|
import type { PaymentConfig } from "./config.ts";
|
||||||
|
|
||||||
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||||
const extension = basename(dirname(fileURLToPath(import.meta.url))) === "dist" ? ".js" : ".ts";
|
const extension = basename(dirname(fileURLToPath(import.meta.url))) === "dist" ? ".js" : ".ts";
|
||||||
@@ -39,17 +40,23 @@ export function paymentPlugin() {
|
|||||||
: [];
|
: [];
|
||||||
},
|
},
|
||||||
configure(config, context) {
|
configure(config, context) {
|
||||||
const value = config.payment as
|
const value = config.payment as PaymentConfig | undefined;
|
||||||
{ default?: string; gateways?: Record<string, unknown>; sandbox?: boolean } | undefined;
|
|
||||||
context.metadata.set(key, Boolean(value));
|
context.metadata.set(key, Boolean(value));
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
if (!value.default) throw new Error("WRN-PAYMENT-CONFIG: payment.default is required");
|
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(
|
throw new Error(
|
||||||
`WRN-PAYMENT-CONFIG: default gateway '${value.default}' has no configuration`,
|
`WRN-PAYMENT-CONFIG: default gateway '${value.default}' has no configuration`,
|
||||||
);
|
);
|
||||||
if (process.env.NODE_ENV === "production" && value.sandbox)
|
if (
|
||||||
throw new Error("WRN-PAYMENT-CONFIG: sandbox cannot be enabled in production");
|
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) {
|
migrations(context: PluginContext) {
|
||||||
return context.metadata.get(key)
|
return context.metadata.get(key)
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
Reference in new issue
Block a user