110 lines
5.5 KiB
TypeScript
110 lines
5.5 KiB
TypeScript
import { basename, dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { definePlugin, type PluginContext } from "@wrnexus/plugin";
|
|
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);
|
|
CREATE TABLE IF NOT EXISTS wrn_payment_event (id TEXT PRIMARY KEY, intent_id TEXT NOT NULL, type TEXT NOT NULL, status TEXT NOT NULL, created_at BIGINT NOT NULL, payload_json TEXT, FOREIGN KEY(intent_id) REFERENCES wrn_payment_intent(id));
|
|
CREATE INDEX IF NOT EXISTS wrn_payment_event_intent ON wrn_payment_event(intent_id, created_at, id);
|
|
CREATE TABLE IF NOT EXISTS wrn_payment_refund (id TEXT PRIMARY KEY, intent_id TEXT NOT NULL, gateway_ref TEXT NOT NULL, amount BIGINT NOT NULL, currency TEXT NOT NULL, reason TEXT NOT NULL, idempotency_key TEXT NOT NULL UNIQUE, status TEXT NOT NULL, created_at BIGINT NOT NULL, FOREIGN KEY(intent_id) REFERENCES wrn_payment_intent(id));
|
|
CREATE TABLE IF NOT EXISTS wrn_payment_method (id TEXT PRIMARY KEY, subject TEXT NOT NULL, gateway TEXT NOT NULL, gateway_ref TEXT NOT NULL, brand TEXT, last4 TEXT, expires TEXT, is_default INTEGER NOT NULL DEFAULT 0, created_at BIGINT NOT NULL);
|
|
CREATE TABLE IF NOT EXISTS wrn_payment_customer (subject TEXT NOT NULL, gateway TEXT NOT NULL, gateway_ref TEXT NOT NULL, created_at BIGINT NOT NULL, PRIMARY KEY(subject,gateway));
|
|
-- +down
|
|
DROP TABLE IF EXISTS wrn_payment_customer;
|
|
DROP TABLE IF EXISTS wrn_payment_method;
|
|
DROP TABLE IF EXISTS wrn_payment_refund;
|
|
DROP TABLE IF EXISTS wrn_payment_event;
|
|
DROP TABLE IF EXISTS wrn_payment_intent;`;
|
|
const orderingMigration = `-- +up
|
|
ALTER TABLE wrn_payment_event ADD COLUMN occurred_at BIGINT;
|
|
ALTER TABLE wrn_payment_event ADD COLUMN gateway_sequence TEXT;
|
|
UPDATE wrn_payment_event SET occurred_at = created_at WHERE occurred_at IS NULL;
|
|
CREATE INDEX IF NOT EXISTS wrn_payment_event_gateway_order ON wrn_payment_event(intent_id, occurred_at, gateway_sequence, id);
|
|
-- +down
|
|
DROP INDEX IF EXISTS wrn_payment_event_gateway_order;`;
|
|
|
|
export function paymentPlugin() {
|
|
const key = "@wrnexus/payment:enabled";
|
|
return definePlugin({
|
|
name: "@wrnexus/payment",
|
|
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) {
|
|
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)
|
|
throw new Error(
|
|
`WRN-PAYMENT-CONFIG: default gateway '${value.default}' has no configuration`,
|
|
);
|
|
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)
|
|
? [
|
|
{ id: "wrnexus-payment-001", source: migration },
|
|
{ id: "wrnexus-payment-002-event-order", source: orderingMigration },
|
|
]
|
|
: [];
|
|
},
|
|
documentation: [join(root, "README.md")],
|
|
});
|
|
}
|
|
export default paymentPlugin;
|