Initial Commit
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user