Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,119 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { EventBus } from "@suaveplan/events";
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
createPaymentPlugin,
resetPaymentInstruments,
} from "../../src/index.js";
import type { PaymentPluginConfig } from "../../src/types/types.js";
import { FakePaymentProvider } from "../setup/fake-provider.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
const SECRET = "shared-webhook-secret-32-chars-or-more";
async function boot(setPrincipalId: string | null = "u1") {
resetPaymentInstruments();
const app = new OpenAPIHono();
app.use("*", async (c, next) => {
c.set(
"user",
setPrincipalId === null
? null
: { id: setPrincipalId, emailVerified: true, roles: [] }
);
await next();
});
const provider = new FakePaymentProvider();
const services = new Map<string, unknown>();
services.set("app", app);
services.set(
"openAPIRegistry",
createOpenAPIRegistry({ info: { title: "t", version: "0.1" } })
);
const plugin = createPaymentPlugin({ provider, webhookSecret: SECRET });
await plugin.onInit?.(
createPluginContext<PaymentPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as PaymentPluginConfig,
services,
})
);
return { app, provider };
}
beforeEach(() => {
resetPaymentInstruments();
});
describe("payment plugin — error paths", () => {
it("currency mismatch on refund returns 422", {
timeout: 60_000,
}, async () => {
const { app } = await boot();
const created = await app.request("/payments/intents", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
amount: { amount: "100", currency: "USD" },
}),
});
const { intent } = (await created.json()) as {
intent: { id: string };
};
await app.request(`/payments/intents/${intent.id}/capture`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
});
const refundRes = await app.request(
`/payments/intents/${intent.id}/refund`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
amount: { amount: "10", currency: "EUR" },
}),
}
);
expect(refundRes.status).toBe(422);
const refundBody = (await refundRes.json()) as {
error: { code: string };
};
expect(refundBody.error.code).toBe("PAYMENT_PLUGIN.CURRENCY_MISMATCH");
});
it("foreign intent capture returns 404", { timeout: 60_000 }, async () => {
const { app } = await boot("u1");
const created = await app.request("/payments/intents", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
amount: { amount: "100", currency: "USD" },
}),
});
const { intent } = (await created.json()) as {
intent: { id: string };
};
const otherApp = await boot("u2");
const captureRes = await otherApp.app.request(
`/payments/intents/${intent.id}/capture`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
}
);
expect(captureRes.status).toBe(404);
});
});

View File

@@ -0,0 +1,232 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { EventBus } from "@suaveplan/events";
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
createPaymentPlugin,
resetPaymentInstruments,
} from "../../src/index.js";
import type { PaymentPluginConfig } from "../../src/types/types.js";
import { FakePaymentProvider } from "../setup/fake-provider.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
const SECRET = "shared-webhook-secret-32-chars-or-more";
interface BootResult {
app: OpenAPIHono;
provider: FakePaymentProvider;
setPrincipal: (id: string | null) => void;
}
async function boot(): Promise<BootResult> {
resetPaymentInstruments();
const app = new OpenAPIHono();
let currentPrincipalId: string | null = "u1";
app.use("*", async (c, next) => {
if (currentPrincipalId === null) {
c.set("user", null);
} else {
c.set("user", {
id: currentPrincipalId,
emailVerified: true,
roles: [],
});
}
await next();
});
const provider = new FakePaymentProvider();
const services = new Map<string, unknown>();
services.set("app", app);
const registry = createOpenAPIRegistry({
info: { title: "test", version: "0.1" },
});
services.set("openAPIRegistry", registry);
const plugin = createPaymentPlugin({
provider,
webhookSecret: SECRET,
});
await plugin.onInit?.(
createPluginContext<PaymentPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as PaymentPluginConfig,
services,
})
);
return {
app,
provider,
setPrincipal: (id) => {
currentPrincipalId = id;
},
};
}
async function jsonReq(
app: OpenAPIHono,
path: string,
init: { method: string; body?: unknown; headers?: Record<string, string> }
) {
const headers: Record<string, string> = {
"content-type": "application/json",
...(init.headers ?? {}),
};
const reqInit: RequestInit = { method: init.method, headers };
if (init.body !== undefined) {
reqInit.body = JSON.stringify(init.body);
}
const res = await app.request(path, reqInit);
const text = await res.text();
let parsed: unknown = null;
try {
parsed = text.length > 0 ? JSON.parse(text) : null;
} catch {
parsed = text;
}
return { res, body: parsed as Record<string, unknown> | null };
}
beforeEach(() => {
resetPaymentInstruments();
});
describe("payment plugin — full lifecycle", () => {
it("create → confirm → capture → refund → get", {
timeout: 60_000,
}, async () => {
const { app, setPrincipal } = await boot();
setPrincipal("u1");
const created = await jsonReq(app, "/payments/intents", {
method: "POST",
body: {
amount: { amount: "1000", currency: "USD" },
captureMethod: "manual",
},
});
expect(created.res.status).toBe(201);
const intentId = (created.body?.["intent"] as { id: string }).id;
const confirmed = await jsonReq(
app,
`/payments/intents/${intentId}/confirm`,
{ method: "POST", body: {} }
);
expect(confirmed.res.status).toBe(200);
const captured = await jsonReq(
app,
`/payments/intents/${intentId}/capture`,
{ method: "POST", body: {} }
);
expect(captured.res.status).toBe(200);
expect((captured.body?.["intent"] as { status: string }).status).toBe(
"captured"
);
const refunded = await jsonReq(
app,
`/payments/intents/${intentId}/refund`,
{
method: "POST",
body: {
amount: { amount: "400", currency: "USD" },
reason: "requested_by_customer",
},
}
);
expect(refunded.res.status).toBe(200);
const final = await jsonReq(app, `/payments/intents/${intentId}`, {
method: "GET",
});
expect(final.res.status).toBe(200);
const finalIntent = final.body?.["intent"] as {
status: string;
refundedAmount: { amount: string };
};
expect(finalIntent.status).toBe("partially_refunded");
});
it("foreign intent returns 404 anti-enumeration", {
timeout: 60_000,
}, async () => {
const { app, setPrincipal } = await boot();
setPrincipal("u1");
const created = await jsonReq(app, "/payments/intents", {
method: "POST",
body: { amount: { amount: "100", currency: "USD" } },
});
const intentId = (created.body?.["intent"] as { id: string }).id;
setPrincipal("u2");
const foreign = await jsonReq(app, `/payments/intents/${intentId}`, {
method: "GET",
});
expect(foreign.res.status).toBe(404);
const missing = await jsonReq(
app,
"/payments/intents/pi_does_not_exist",
{ method: "GET" }
);
expect(missing.res.status).toBe(404);
});
it("list returns only owner-owned intents", {
timeout: 60_000,
}, async () => {
const { app, setPrincipal } = await boot();
setPrincipal("u1");
await jsonReq(app, "/payments/intents", {
method: "POST",
body: { amount: { amount: "100", currency: "USD" } },
});
await jsonReq(app, "/payments/intents", {
method: "POST",
body: { amount: { amount: "200", currency: "USD" } },
});
setPrincipal("u2");
await jsonReq(app, "/payments/intents", {
method: "POST",
body: { amount: { amount: "300", currency: "USD" } },
});
setPrincipal("u1");
const list = await jsonReq(app, "/payments/intents", {
method: "GET",
});
expect(list.res.status).toBe(200);
expect((list.body?.["intents"] as readonly unknown[]).length).toBe(2);
});
it("cancel route works", { timeout: 60_000 }, async () => {
const { app, setPrincipal } = await boot();
setPrincipal("u1");
const created = await jsonReq(app, "/payments/intents", {
method: "POST",
body: { amount: { amount: "100", currency: "USD" } },
});
const intentId = (created.body?.["intent"] as { id: string }).id;
const canceled = await jsonReq(
app,
`/payments/intents/${intentId}/cancel`,
{ method: "POST", body: {} }
);
expect(canceled.res.status).toBe(200);
expect((canceled.body?.["intent"] as { status: string }).status).toBe(
"canceled"
);
});
});

View File

@@ -0,0 +1,108 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { EventBus } from "@suaveplan/events";
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
createPaymentPlugin,
resetPaymentInstruments,
} from "../../src/index.js";
import type { PaymentPluginConfig } from "../../src/types/types.js";
import { FakePaymentProvider } from "../setup/fake-provider.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
const SECRET = "shared-webhook-secret-32-chars-or-more";
async function boot() {
resetPaymentInstruments();
const app = new OpenAPIHono();
app.use("*", async (c, next) => {
c.set("user", { id: "u1", emailVerified: true, roles: [] });
await next();
});
const provider = new FakePaymentProvider();
const services = new Map<string, unknown>();
services.set("app", app);
const registry = createOpenAPIRegistry({
info: { title: "test", version: "0.1" },
});
services.set("openAPIRegistry", registry);
const plugin = createPaymentPlugin({ provider, webhookSecret: SECRET });
await plugin.onInit?.(
createPluginContext<PaymentPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as PaymentPluginConfig,
services,
})
);
return { app, provider };
}
beforeEach(() => {
resetPaymentInstruments();
});
describe("payment plugin — idempotency", () => {
it("second create with same key returns same intent (no duplicate)", {
timeout: 60_000,
}, async () => {
const { app, provider } = await boot();
const headers = {
"content-type": "application/json",
"Idempotency-Key": "order-1",
};
const a = await app.request("/payments/intents", {
method: "POST",
headers,
body: JSON.stringify({
amount: { amount: "100", currency: "USD" },
}),
});
const b = await app.request("/payments/intents", {
method: "POST",
headers,
body: JSON.stringify({
amount: { amount: "100", currency: "USD" },
}),
});
const aBody = (await a.json()) as { intent: { id: string } };
const bBody = (await b.json()) as { intent: { id: string } };
expect(bBody.intent.id).toBe(aBody.intent.id);
// Provider should have been called once.
const list = await provider.getIntent(aBody.intent.id);
expect(list.id).toBe(aBody.intent.id);
});
it("different body under same key returns 409", {
timeout: 60_000,
}, async () => {
const { app } = await boot();
const headers = {
"content-type": "application/json",
"Idempotency-Key": "order-2",
};
await app.request("/payments/intents", {
method: "POST",
headers,
body: JSON.stringify({
amount: { amount: "100", currency: "USD" },
}),
});
const second = await app.request("/payments/intents", {
method: "POST",
headers,
body: JSON.stringify({
amount: { amount: "200", currency: "USD" },
}),
});
expect(second.status).toBe(409);
});
});

View File

@@ -0,0 +1,123 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { EventBus } from "@suaveplan/events";
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
createPaymentPlugin,
resetPaymentInstruments,
signPaymentWebhook,
} from "../../src/index.js";
import type { PaymentPluginConfig } from "../../src/types/types.js";
import { FakePaymentProvider } from "../setup/fake-provider.js";
import { flushTestMetrics } from "../setup/polyfills.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
const SECRET = "shared-webhook-secret-32-chars-or-more";
async function boot() {
resetPaymentInstruments();
const app = new OpenAPIHono();
app.use("*", async (c, next) => {
c.set("user", null);
await next();
});
const provider = new FakePaymentProvider();
const services = new Map<string, unknown>();
services.set("app", app);
services.set(
"openAPIRegistry",
createOpenAPIRegistry({ info: { title: "t", version: "0.1" } })
);
const plugin = createPaymentPlugin({ provider, webhookSecret: SECRET });
await plugin.onInit?.(
createPluginContext<PaymentPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as PaymentPluginConfig,
services,
})
);
return { app, provider };
}
beforeEach(() => {
resetPaymentInstruments();
});
describe("payment plugin — webhook", () => {
it("valid HMAC accepted", { timeout: 60_000 }, async () => {
const { app } = await boot();
const body = JSON.stringify({
id: "evt_1",
type: "intent.captured",
timestamp: 1,
data: {},
});
const ts = Date.now();
const sig = signPaymentWebhook({
body,
timestamp: ts,
secret: SECRET,
algorithm: "SHA-256",
});
const res = await app.request("/payments/webhook", {
method: "POST",
headers: {
"content-type": "application/json",
"X-Signature": sig,
"X-Timestamp": String(ts),
},
body,
});
expect(res.status).toBe(200);
const parsed = (await res.json()) as { accepted: boolean };
expect(parsed.accepted).toBe(true);
const collected = await flushTestMetrics();
const names = collected.flatMap((c) =>
c.scopeMetrics.flatMap((sm) =>
sm.metrics.map((m) => m.descriptor.name)
)
);
expect(names).toContain("payments.webhook_received_total");
});
it("invalid HMAC rejected before deserialisation", {
timeout: 60_000,
}, async () => {
const { app } = await boot();
const body = JSON.stringify({
id: "evt_1",
type: "intent.captured",
timestamp: 1,
data: {},
});
const ts = Date.now();
const res = await app.request("/payments/webhook", {
method: "POST",
headers: {
"content-type": "application/json",
"X-Signature": "deadbeef",
"X-Timestamp": String(ts),
},
body,
});
expect(res.status).toBe(400);
const collected = await flushTestMetrics();
const names = collected.flatMap((c) =>
c.scopeMetrics.flatMap((sm) =>
sm.metrics.map((m) => m.descriptor.name)
)
);
expect(names).toContain("payments.webhook_signature_failures_total");
});
});

View File

@@ -0,0 +1,168 @@
import type {
CreatePaymentIntentInput,
PaymentIntent,
PaymentProvider,
PaymentStatus,
Refund,
RefundInput,
} from "@suaveplan/payment";
interface MoneyLike {
readonly amount: bigint;
readonly currency: string;
}
function nowIso() {
return new Date().toISOString();
}
function makeMoney(amount: bigint, currency: string): MoneyLike {
return { amount, currency };
}
/**
* Test-only `PaymentProvider` implementing the real interface from
* `@suaveplan/payment`. Stores intents and refunds in two in-memory
* maps so the tests can assert against deterministic state. No
* external IO.
*/
export class FakePaymentProvider implements PaymentProvider {
public readonly name = "fake";
private readonly intents = new Map<string, PaymentIntent>();
private readonly idemMap = new Map<string, string>();
private seq = 0;
public failCreate = false;
public returnEmptyOnCreate = false;
public reset(): void {
this.intents.clear();
this.idemMap.clear();
this.seq = 0;
this.failCreate = false;
this.returnEmptyOnCreate = false;
}
public async createIntent(
input: CreatePaymentIntentInput
): Promise<PaymentIntent> {
if (this.failCreate) {
throw new Error("simulated provider failure");
}
if (this.returnEmptyOnCreate) {
return null as unknown as PaymentIntent;
}
if (input.idempotencyKey !== undefined) {
const existingId = this.idemMap.get(input.idempotencyKey);
if (existingId !== undefined) {
const found = this.intents.get(existingId);
if (found) return found;
}
}
this.seq += 1;
const id = `pi_${this.seq}`;
const intent: PaymentIntent = {
id,
status: input.captureMethod === "manual" ? "created" : "created",
amount: makeMoney(input.amount.amount, input.amount.currency),
capturedAmount: makeMoney(0n, input.amount.currency),
refundedAmount: makeMoney(0n, input.amount.currency),
captureMethod: input.captureMethod ?? "automatic",
refunds: [],
createdAt: nowIso(),
updatedAt: nowIso(),
...(input.description !== undefined
? { description: input.description }
: {}),
...(input.metadata !== undefined
? { metadata: input.metadata }
: {}),
...(input.idempotencyKey !== undefined
? { idempotencyKey: input.idempotencyKey }
: {}),
};
this.intents.set(id, intent);
if (input.idempotencyKey !== undefined) {
this.idemMap.set(input.idempotencyKey, id);
}
return intent;
}
public async confirmIntent(id: string): Promise<PaymentIntent> {
return this.updateStatus(id, "authorized");
}
public async captureIntent(
id: string,
amount?: MoneyLike
): Promise<PaymentIntent> {
const current = this.mustGet(id);
const captureAmt = amount?.amount ?? current.amount.amount;
const newStatus: PaymentStatus = "captured";
const updated: PaymentIntent = {
...current,
status: newStatus,
capturedAmount: makeMoney(captureAmt, current.amount.currency),
updatedAt: nowIso(),
};
this.intents.set(id, updated);
return updated;
}
public async cancelIntent(id: string): Promise<PaymentIntent> {
return this.updateStatus(id, "canceled");
}
public async refund(input: RefundInput): Promise<Refund> {
const current = this.mustGet(input.paymentIntentId);
const amt = input.amount?.amount ?? current.capturedAmount.amount;
const refundedSoFar = current.refundedAmount.amount + amt;
const refund: Refund = {
id: `re_${this.seq + 1}`,
paymentIntentId: input.paymentIntentId,
amount: makeMoney(amt, current.amount.currency),
status: "succeeded",
...(input.reason !== undefined ? { reason: input.reason } : {}),
createdAt: nowIso(),
...(input.metadata !== undefined
? { metadata: input.metadata }
: {}),
};
this.seq += 1;
const newStatus: PaymentStatus =
refundedSoFar >= current.capturedAmount.amount
? "refunded"
: "partially_refunded";
const updated: PaymentIntent = {
...current,
status: newStatus,
refundedAmount: makeMoney(refundedSoFar, current.amount.currency),
refunds: [...current.refunds, refund],
updatedAt: nowIso(),
};
this.intents.set(current.id, updated);
return refund;
}
public async getIntent(id: string): Promise<PaymentIntent> {
return this.mustGet(id);
}
private mustGet(id: string): PaymentIntent {
const found = this.intents.get(id);
if (found === undefined) {
throw new Error(`provider has no intent "${id}"`);
}
return found;
}
private updateStatus(id: string, status: PaymentStatus): PaymentIntent {
const current = this.mustGet(id);
const updated: PaymentIntent = {
...current,
status,
updatedAt: nowIso(),
};
this.intents.set(id, updated);
return updated;
}
}

View File

@@ -0,0 +1,37 @@
// Side-effect imports — surface the Hono ContextVariableMap augmentation
// (`user: Principal | null`) so tests can `c.set("user", ...)` with the
// expected typing.
import "@suaveplan/api-plugin-auth";
/**
* Test setup for @suaveplan/api-plugin-payment.
*
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
* module imports so the meter resolved on first call binds to it.
*/
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics() {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}