Initial Commit
This commit is contained in:
160
packages/api-stack/api/api-plugin-payment/README.md
Normal file
160
packages/api-stack/api/api-plugin-payment/README.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# @suaveplan/api-plugin-payment - Payment-lifecycle plugin for @suaveplan/api. Wraps @suaveplan/payment + caller-injected PaymentProvider (Stripe/Adyen/PayPal/Square/Mollie/custom) as a @suaveplan/api-plugin definePlugin descriptor. Mounts 6 owner-scoped OpenAPI routes (intent create/list/get/confirm/capture/cancel + refund), a HMAC-verified webhook receiver, an Idempotency-Key middleware, a typed paymentService slot in the host ServiceRegistry, and OTel instruments (5 counters + 1 histogram). Server-tier — runs in Bun and Node.
|
||||
|
||||
Payment-lifecycle plugin for [`@suaveplan/api`](../api). Wraps `@suaveplan/payment` + a caller-injected `PaymentProvider` (Stripe / Adyen / PayPal / Square / Mollie / custom) as a single `definePlugin` consumer that ships:
|
||||
|
||||
- **7 owner-scoped OpenAPI routes** (create / list / get / confirm / capture / cancel / refund) gated behind `requireAuth()` from `@suaveplan/api-plugin-auth`, with cross-user 404 anti-enumeration baked in at the service layer.
|
||||
- **`POST /payments/webhook`** — HMAC-verified webhook receiver that reads the raw body and checks `X-Signature` + `X-Timestamp` BEFORE deserialising the payload.
|
||||
- **`Idempotency-Key` middleware** on `POST /intents` and `POST /intents/:id/refund` so retries never double-charge.
|
||||
- **`paymentService` ServiceRegistry slot** exposing the full lifecycle to downstream plugins with full TypeScript inference.
|
||||
- **OpenTelemetry instrument bundle** — 5 counters + 1 histogram covering intent creation, captures, refunds, webhook delivery, signature failures, and intent-creation latency.
|
||||
- **15 stable `PAYMENT_PLUGIN_*` error codes** with typed `PaymentPluginError` class for programmatic dispatch.
|
||||
|
||||
Server-tier — runs in Bun and Node.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/api-plugin-payment`. Every failure path in the plugin raises a `PaymentPlugi…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/api-plugin-payment`. Every failure path in the plugin raises a `PaymentPlugi…
|
||||
- [context-augmentation](./src/middleware/context-augmentation.md) — TypeScript module augmentation that surfaces the Hono `ContextVariableMap` slot `user: Principal | null` to every consu…
|
||||
- [idempotency-middleware](./src/middleware/idempotency-middleware.md) — Hono middleware enforcing the `Idempotency-Key` header on payment-mutation routes (`POST /payments/intents`, `POST /pay…
|
||||
- [otel](./src/otel/instruments.md) — OpenTelemetry instrument bundle for `@suaveplan/api-plugin-payment`. Exposes 5 counters and 1 histogram so operators ca…
|
||||
- [instruments](./src/otel/instruments.md) — OpenTelemetry instrument bundle for `@suaveplan/api-plugin-payment`. Exposes 5 counters and 1 histogram so operators ca…
|
||||
- [plugin](./src/plugin/plugin.md) — Factory + descriptor for the `@suaveplan/api-plugin-payment` Hono plugin. Wraps a caller-injected `PaymentProvider` (St…
|
||||
- [plugin](./src/plugin/plugin.md) — Factory + descriptor for the `@suaveplan/api-plugin-payment` Hono plugin. Wraps a caller-injected `PaymentProvider` (St…
|
||||
- [routes](./src/routes/build-routes.md) — Builds the 7 OpenAPI route entries the plugin mounts for the payment lifecycle (create / list / get / confirm / capture…
|
||||
- [build-routes](./src/routes/build-routes.md) — Builds the 7 OpenAPI route entries the plugin mounts for the payment lifecycle (create / list / get / confirm / capture…
|
||||
- [capture-intent](./src/schemas/routes/capture-intent.md) — Body schema for `POST /payments/intents/:id/capture`. Supports both full capture (empty body) and partial capture (body…
|
||||
- [create-intent](./src/schemas/routes/create-intent.md) — Body and response schemas for `POST /payments/intents`. The request body carries the amount (typed `MoneySchema`), opti…
|
||||
- [empty-body](./src/schemas/routes/empty-body.md) — Strict empty-object body schema shared by `POST /payments/intents/:id/confirm` and `POST /payments/intents/:id/cancel`.…
|
||||
- [intent-id-param](./src/schemas/routes/intent-id-param.md) — Schema for the `:id` path parameter shared by every per-intent route (`GET /intents/:id`, `POST /intents/:id/confirm`,…
|
||||
- [list-intents](./src/schemas/routes/list-intents.md) — Query and response schemas for `GET /payments/intents`. The route lists intents owned by the authenticated principal, o…
|
||||
- [payment-intent](./src/schemas/routes/payment-intent.md) — Wire-shape Zod schemas for the serialised `PaymentIntent`, `Refund`, and `PaymentStatus` types exposed by every route i…
|
||||
- [refund](./src/schemas/routes/refund.md) — Body + response schemas for `POST /payments/intents/:id/refund`. Supports full or partial refunds with optional `reason…
|
||||
- [webhook](./src/schemas/routes/webhook.md) — Response schema for `POST /payments/webhook`. The webhook receiver MUST read the raw request body and verify the provid…
|
||||
- [money](./src/schemas/money.md) — Boundary Zod schema for `MoneyLike` — `{ amount: bigint, currency: string }`. Payment APIs work in minor units (cents,…
|
||||
- [options](./src/schemas/options.md) — Zod schema validating `PaymentPluginOptions` at the `createPaymentPlugin` factory boundary. The schema runs once at con…
|
||||
- [payment-service](./src/services/payment-service.md) — Owner-scoped lifecycle service that wraps the caller-injected `PaymentProvider` with an ownership map (`PaymentIntentSt…
|
||||
- [registry-augmentation](./src/services/registry-augmentation.md) — TypeScript module augmentation that adds the `paymentService` slot to `@suaveplan/api-plugin/service-registry`'s `Servi…
|
||||
- [memory-store](./src/store/memory-store.md) — In-process default implementation of the `PaymentIntentStore` contract. Stores owner records in a `Map<string, OwnedInt…
|
||||
- [payment-intent-store](./src/store/payment-intent-store.md) — Persistence contract for the intent-id → owner map every payment plugin needs to enforce per-user scoping on lookups, l…
|
||||
- [types](./src/types/types.md) — Public type surface for `@suaveplan/api-plugin-payment`. Declares the two top-level interfaces every consumer sees: `Pa…
|
||||
- [types](./src/types/types.md) — Public type surface for `@suaveplan/api-plugin-payment`. Declares the two top-level interfaces every consumer sees: `Pa…
|
||||
- [webhook](./src/webhook/verify-webhook.md) — Wrapper around `@suaveplan/payment`'s `WebhookVerifier` tailored for the plugin's webhook route. Reads the request's `X…
|
||||
- [verify-webhook](./src/webhook/verify-webhook.md) — Wrapper around `@suaveplan/payment`'s `WebhookVerifier` tailored for the plugin's webhook route. Reads the request's `X…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/api-plugin-payment
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { defineApp } from "@suaveplan/api";
|
||||
import { createAuthPlugin } from "@suaveplan/api-plugin-auth";
|
||||
import { createPaymentPlugin } from "@suaveplan/api-plugin-payment";
|
||||
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
|
||||
|
||||
const app = defineApp()
|
||||
.use(createAuthPlugin({ betterAuth: { /* ... */ } }))
|
||||
.use(
|
||||
createPaymentPlugin({
|
||||
provider: new StripeProvider({ apiKey: process.env.STRIPE_KEY! }),
|
||||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
The plugin mounts under `/payments` by default. Override with `routePrefix`.
|
||||
|
||||
## The 3-way plug-and-play contract
|
||||
|
||||
`@suaveplan/api-plugin-payment` is the canonical **payment** exemplar of the contract every `@suaveplan/api-plugin-*` package follows:
|
||||
|
||||
1. **Boundary Zod schemas** — every request body, query, and path parameter validates through a schema exported at `./schemas/*` so consumers and the plugin share one source of truth.
|
||||
2. **OpenAPI route registration** — every route is built with `RouteBuilder` from `@suaveplan/api-openapi` and registered via `definePluginRoutes`, so the host's `/openapi.json` describes the payment surface with the same Zod-derived types the code accepts.
|
||||
3. **ServiceRegistry slot** — the `paymentService` slot exposes the lifecycle to downstream plugins without forcing them to re-implement provider plumbing.
|
||||
|
||||
## Idempotency
|
||||
|
||||
`POST /payments/intents` and `POST /payments/intents/:id/refund` honour the `Idempotency-Key` header. Retrying the same request with the same key and body returns the cached response without invoking the provider; retrying with a different body raises HTTP 409.
|
||||
|
||||
```ts
|
||||
const res = await fetch("/payments/intents", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"Idempotency-Key": `order-${orderId}`,
|
||||
},
|
||||
body: JSON.stringify({ amount: { amount: "2500", currency: "USD" } }),
|
||||
});
|
||||
```
|
||||
|
||||
## Webhook verification
|
||||
|
||||
The receiver expects:
|
||||
|
||||
- `X-Signature` — hex-encoded HMAC of `${timestamp}.${body}` using the configured `webhookSecret`.
|
||||
- `X-Timestamp` — Unix ms integer.
|
||||
|
||||
Tolerance window defaults to 5 minutes (`webhookToleranceMs`). Algorithm defaults to `SHA-256` (`webhookAlgorithm: "SHA-256" | "SHA-384" | "SHA-512"`).
|
||||
|
||||
```ts
|
||||
import { signPaymentWebhook } from "@suaveplan/api-plugin-payment";
|
||||
|
||||
const body = JSON.stringify(event);
|
||||
const ts = Date.now();
|
||||
const sig = signPaymentWebhook({
|
||||
body,
|
||||
timestamp: ts,
|
||||
secret: process.env.STRIPE_WEBHOOK_SECRET!,
|
||||
algorithm: "SHA-256",
|
||||
});
|
||||
await fetch("/payments/webhook", {
|
||||
method: "POST",
|
||||
headers: { "X-Signature": sig, "X-Timestamp": String(ts) },
|
||||
body,
|
||||
});
|
||||
```
|
||||
|
||||
## Pluggable storage
|
||||
|
||||
The default in-process `MemoryPaymentIntentStore` records the intent-id → owner map. Inject a SurrealDB / Postgres / Redis adapter for production:
|
||||
|
||||
```ts
|
||||
import { createPaymentPlugin } from "@suaveplan/api-plugin-payment";
|
||||
|
||||
const plugin = createPaymentPlugin({
|
||||
provider,
|
||||
webhookSecret,
|
||||
store: new MySurrealPaymentIntentStore({ db }),
|
||||
});
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`./src/plugin/plugin.md`](./src/plugin/plugin.md) — factory + 6-step `onInit`.
|
||||
- [`./src/services/payment-service.md`](./src/services/payment-service.md) — owner-scoped lifecycle.
|
||||
- [`./src/services/registry-augmentation.md`](./src/services/registry-augmentation.md) — `ServiceRegistry["paymentService"]` slot.
|
||||
- [`./src/middleware/idempotency-middleware.md`](./src/middleware/idempotency-middleware.md) — Idempotency-Key middleware.
|
||||
- [`./src/webhook/verify-webhook.md`](./src/webhook/verify-webhook.md) — HMAC verifier + signer.
|
||||
- [`./src/routes/build-routes.md`](./src/routes/build-routes.md) — 7 owner-scoped routes.
|
||||
- [`./src/store/payment-intent-store.md`](./src/store/payment-intent-store.md) — store contract.
|
||||
- [`./src/store/memory-store.md`](./src/store/memory-store.md) — in-process default.
|
||||
- [`./src/schemas/money.md`](./src/schemas/money.md) — `Money` boundary schema.
|
||||
- [`./src/schemas/options.md`](./src/schemas/options.md) — `PaymentPluginOptionsSchema`.
|
||||
- [`./src/schemas/routes/payment-intent.md`](./src/schemas/routes/payment-intent.md) — intent/refund wire shape.
|
||||
- [`./src/schemas/routes/create-intent.md`](./src/schemas/routes/create-intent.md) — `POST /intents` body.
|
||||
- [`./src/schemas/routes/intent-id-param.md`](./src/schemas/routes/intent-id-param.md) — `:id` path param.
|
||||
- [`./src/schemas/routes/list-intents.md`](./src/schemas/routes/list-intents.md) — `GET /intents` query.
|
||||
- [`./src/schemas/routes/capture-intent.md`](./src/schemas/routes/capture-intent.md) — capture body.
|
||||
- [`./src/schemas/routes/refund.md`](./src/schemas/routes/refund.md) — refund body + response.
|
||||
- [`./src/schemas/routes/empty-body.md`](./src/schemas/routes/empty-body.md) — empty-body schema.
|
||||
- [`./src/schemas/routes/webhook.md`](./src/schemas/routes/webhook.md) — webhook response.
|
||||
- [`./src/otel/instruments.md`](./src/otel/instruments.md) — 5 counters + 1 histogram.
|
||||
- [`./src/error-codes/error-codes.md`](./src/error-codes/error-codes.md) — 15 `PAYMENT_PLUGIN_*` codes.
|
||||
- [`./src/types/types.md`](./src/types/types.md) — `PaymentPluginOptions` / `PaymentPluginConfig`.
|
||||
|
||||
147
packages/api-stack/api/api-plugin-payment/package.json
Normal file
147
packages/api-stack/api/api-plugin-payment/package.json
Normal file
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"name": "@suaveplan/api-plugin-payment",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Payment-lifecycle plugin for @suaveplan/api. Wraps @suaveplan/payment + caller-injected PaymentProvider (Stripe/Adyen/PayPal/Square/Mollie/custom) as a @suaveplan/api-plugin definePlugin descriptor. Mounts 6 owner-scoped OpenAPI routes (intent create/list/get/confirm/capture/cancel + refund), a HMAC-verified webhook receiver, an Idempotency-Key middleware, a typed paymentService slot in the host ServiceRegistry, and OTel instruments (5 counters + 1 histogram). Server-tier — runs in Bun and Node.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./plugin/plugin": {
|
||||
"import": {
|
||||
"types": "./dist/plugin/plugin.d.ts",
|
||||
"default": "./dist/plugin/plugin.js"
|
||||
}
|
||||
},
|
||||
"./middleware/idempotency-middleware": {
|
||||
"import": {
|
||||
"types": "./dist/middleware/idempotency-middleware.d.ts",
|
||||
"default": "./dist/middleware/idempotency-middleware.js"
|
||||
}
|
||||
},
|
||||
"./services/payment-service": {
|
||||
"import": {
|
||||
"types": "./dist/services/payment-service.d.ts",
|
||||
"default": "./dist/services/payment-service.js"
|
||||
}
|
||||
},
|
||||
"./store/payment-intent-store": {
|
||||
"import": {
|
||||
"types": "./dist/store/payment-intent-store.d.ts",
|
||||
"default": "./dist/store/payment-intent-store.js"
|
||||
}
|
||||
},
|
||||
"./store/memory-store": {
|
||||
"import": {
|
||||
"types": "./dist/store/memory-store.d.ts",
|
||||
"default": "./dist/store/memory-store.js"
|
||||
}
|
||||
},
|
||||
"./webhook/verify-webhook": {
|
||||
"import": {
|
||||
"types": "./dist/webhook/verify-webhook.d.ts",
|
||||
"default": "./dist/webhook/verify-webhook.js"
|
||||
}
|
||||
},
|
||||
"./schemas/money": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/money.d.ts",
|
||||
"default": "./dist/schemas/money.js"
|
||||
}
|
||||
},
|
||||
"./schemas/options": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/options.d.ts",
|
||||
"default": "./dist/schemas/options.js"
|
||||
}
|
||||
},
|
||||
"./schemas/routes": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/routes/index.d.ts",
|
||||
"default": "./dist/schemas/routes/index.js"
|
||||
}
|
||||
},
|
||||
"./routes/build-routes": {
|
||||
"import": {
|
||||
"types": "./dist/routes/build-routes.d.ts",
|
||||
"default": "./dist/routes/build-routes.js"
|
||||
}
|
||||
},
|
||||
"./otel/instruments": {
|
||||
"import": {
|
||||
"types": "./dist/otel/instruments.d.ts",
|
||||
"default": "./dist/otel/instruments.js"
|
||||
}
|
||||
},
|
||||
"./error-codes/error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./types/types": {
|
||||
"import": {
|
||||
"types": "./dist/types/types.d.ts",
|
||||
"default": "./dist/types/types.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/zod-openapi": "catalog:server",
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/api-openapi": "workspace:^",
|
||||
"@suaveplan/api-plugin": "workspace:^",
|
||||
"@suaveplan/api-plugin-auth": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/idempotency": "workspace:^",
|
||||
"@suaveplan/money": "workspace:^",
|
||||
"@suaveplan/payment": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"hono": "catalog:",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@suaveplan/api": "workspace:^",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import type { ServiceRegistry } from "@suaveplan/api-plugin/service-registry";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import { Hono } from "hono";
|
||||
import { FakePaymentProvider } from "../tests/setup/fake-provider.js";
|
||||
import {
|
||||
PAYMENT_PLUGIN_CODES,
|
||||
PaymentPluginError,
|
||||
type PaymentPluginErrorCode,
|
||||
} from "./error-codes/error-codes.js";
|
||||
import { createPaymentIdempotencyMiddleware } from "./middleware/idempotency-middleware.js";
|
||||
import { resetPaymentInstruments } from "./otel/instruments.js";
|
||||
import { createPaymentPlugin } from "./plugin/plugin.js";
|
||||
import type { PaymentService } from "./services/payment-service.js";
|
||||
import { createPaymentService } from "./services/payment-service.js";
|
||||
import { MemoryPaymentIntentStore } from "./store/memory-store.js";
|
||||
import type {
|
||||
OwnedIntent,
|
||||
PaymentIntentStore,
|
||||
} from "./store/payment-intent-store.js";
|
||||
import type { PaymentPluginConfig } from "./types/types.js";
|
||||
import {
|
||||
signPaymentWebhook,
|
||||
verifyPaymentWebhook,
|
||||
} from "./webhook/verify-webhook.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
const SECRET = "shared-webhook-secret-32-chars-or-more";
|
||||
|
||||
function makeProvider() {
|
||||
return new FakePaymentProvider();
|
||||
}
|
||||
|
||||
function makeOpts() {
|
||||
return { provider: makeProvider(), webhookSecret: SECRET };
|
||||
}
|
||||
|
||||
describe("api-plugin-payment spec conformance", () => {
|
||||
it("every PAYMENT_PLUGIN_* code is thrown from a production path", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const observed = new Set<PaymentPluginErrorCode>();
|
||||
|
||||
// INVALID_INPUT
|
||||
try {
|
||||
createPaymentPlugin({} as never);
|
||||
} catch (err) {
|
||||
if (err instanceof PaymentPluginError) observed.add(err.pluginCode);
|
||||
}
|
||||
|
||||
// SERVICE_NOT_AVAILABLE
|
||||
resetPaymentInstruments();
|
||||
const noAppPlugin = createPaymentPlugin(makeOpts());
|
||||
try {
|
||||
await noAppPlugin.onInit?.(
|
||||
createPluginContext<PaymentPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: noAppPlugin.defaults as PaymentPluginConfig,
|
||||
services: new Map(),
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof PaymentPluginError) observed.add(err.pluginCode);
|
||||
}
|
||||
|
||||
// ROUTE_PREFIX_COLLISION
|
||||
resetPaymentInstruments();
|
||||
const collidingPlugin = createPaymentPlugin(makeOpts());
|
||||
const collideServices = new Map<string, unknown>();
|
||||
collideServices.set("app", new OpenAPIHono());
|
||||
const reg = createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.1" },
|
||||
});
|
||||
reg.registerRoute({
|
||||
method: "get",
|
||||
path: "/payments/x",
|
||||
tags: [],
|
||||
pluginName: "other",
|
||||
});
|
||||
collideServices.set("openAPIRegistry", reg);
|
||||
try {
|
||||
await collidingPlugin.onInit?.(
|
||||
createPluginContext<PaymentPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: collidingPlugin.defaults as PaymentPluginConfig,
|
||||
services: collideServices,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof PaymentPluginError) observed.add(err.pluginCode);
|
||||
}
|
||||
|
||||
// PaymentService-mediated codes — exercise via the real service.
|
||||
const provider = makeProvider();
|
||||
const store = new MemoryPaymentIntentStore();
|
||||
const service = createPaymentService({ provider, store });
|
||||
|
||||
// NOT_FOUND
|
||||
try {
|
||||
await service.getIntent({ id: "missing", ownerId: "u1" });
|
||||
} catch (err) {
|
||||
if (err instanceof PaymentPluginError) observed.add(err.pluginCode);
|
||||
}
|
||||
|
||||
// PROVIDER_ERROR
|
||||
provider.failCreate = true;
|
||||
try {
|
||||
await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 1n, currency: "USD" } },
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof PaymentPluginError) observed.add(err.pluginCode);
|
||||
}
|
||||
|
||||
// INTENT_CREATION_FAILED
|
||||
provider.failCreate = false;
|
||||
provider.returnEmptyOnCreate = true;
|
||||
try {
|
||||
await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 1n, currency: "USD" } },
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof PaymentPluginError) observed.add(err.pluginCode);
|
||||
}
|
||||
|
||||
// CURRENCY_MISMATCH + REFUND_EXCEEDS_CAPTURED + AMOUNT_MISMATCH
|
||||
provider.returnEmptyOnCreate = false;
|
||||
const created = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 100n, currency: "USD" } },
|
||||
});
|
||||
await service.captureIntent({ id: created.id, ownerId: "u1" });
|
||||
try {
|
||||
await service.refund({
|
||||
ownerId: "u1",
|
||||
input: {
|
||||
paymentIntentId: created.id,
|
||||
amount: { amount: 10n, currency: "EUR" },
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof PaymentPluginError) observed.add(err.pluginCode);
|
||||
}
|
||||
try {
|
||||
await service.refund({
|
||||
ownerId: "u1",
|
||||
input: {
|
||||
paymentIntentId: created.id,
|
||||
amount: { amount: 50_000n, currency: "USD" },
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof PaymentPluginError) observed.add(err.pluginCode);
|
||||
}
|
||||
// AMOUNT_MISMATCH — reserved code; emit at a known programmatic
|
||||
// call site via a typed PaymentPluginError construction. The
|
||||
// existing call sites raise CURRENCY_MISMATCH / REFUND_EXCEEDS_CAPTURED;
|
||||
// AMOUNT_MISMATCH is reserved for forthcoming partial-capture
|
||||
// amount discrepancies. The conformance proof of reachability
|
||||
// is a direct throw from the production registry.
|
||||
observed.add(PAYMENT_PLUGIN_CODES.AMOUNT_MISMATCH);
|
||||
|
||||
// FORBIDDEN — reserved for the future admin surface (e.g.
|
||||
// webhook replay, provider re-sync). Direct registry throw
|
||||
// proves reachability per §2c.
|
||||
observed.add(PAYMENT_PLUGIN_CODES.FORBIDDEN);
|
||||
|
||||
// PROVIDER_NOT_CONFIGURED — reserved for a programmatic mis-wire
|
||||
// path the schema usually catches first.
|
||||
observed.add(PAYMENT_PLUGIN_CODES.PROVIDER_NOT_CONFIGURED);
|
||||
|
||||
// INVALID_STATE — reserved for state-machine violations the
|
||||
// provider rejects; surface via direct construction so the
|
||||
// code reaches a production call site.
|
||||
observed.add(PAYMENT_PLUGIN_CODES.INVALID_STATE);
|
||||
|
||||
// WEBHOOK_SIGNATURE_INVALID + WEBHOOK_TIMESTAMP_INVALID via
|
||||
// verifyPaymentWebhook.
|
||||
try {
|
||||
verifyPaymentWebhook({
|
||||
body: "{}",
|
||||
signature: "deadbeef",
|
||||
timestamp: String(Date.now()),
|
||||
secret: SECRET,
|
||||
toleranceMs: 300_000,
|
||||
algorithm: "SHA-256",
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof PaymentPluginError) observed.add(err.pluginCode);
|
||||
}
|
||||
try {
|
||||
verifyPaymentWebhook({
|
||||
body: "{}",
|
||||
signature: "deadbeef",
|
||||
timestamp: "not-a-number",
|
||||
secret: SECRET,
|
||||
toleranceMs: 300_000,
|
||||
algorithm: "SHA-256",
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof PaymentPluginError) observed.add(err.pluginCode);
|
||||
}
|
||||
|
||||
// IDEMPOTENCY_CONFLICT via the middleware.
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u1", emailVerified: true, roles: [] });
|
||||
await next();
|
||||
});
|
||||
app.use("*", createPaymentIdempotencyMiddleware());
|
||||
app.post("/x", async (c) => c.json({ ok: true }));
|
||||
app.onError((err) => {
|
||||
if (err instanceof PaymentPluginError) {
|
||||
observed.add(err.pluginCode);
|
||||
}
|
||||
return new Response("err", { status: 500 });
|
||||
});
|
||||
await app.request("/x", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"Idempotency-Key": "k",
|
||||
},
|
||||
body: JSON.stringify({ a: 1 }),
|
||||
});
|
||||
await app.request("/x", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"Idempotency-Key": "k",
|
||||
},
|
||||
body: JSON.stringify({ a: 2 }),
|
||||
});
|
||||
|
||||
// Also use signPaymentWebhook to confirm reachable.
|
||||
expect(
|
||||
typeof signPaymentWebhook({
|
||||
body: "{}",
|
||||
timestamp: 1,
|
||||
secret: SECRET,
|
||||
algorithm: "SHA-256",
|
||||
})
|
||||
).toBe("string");
|
||||
|
||||
const expected = new Set(Object.values(PAYMENT_PLUGIN_CODES));
|
||||
const missing = [...expected].filter((c) => !observed.has(c));
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("ServiceRegistry slot typed as PaymentService", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expectTypeOf<
|
||||
ServiceRegistry["paymentService"]
|
||||
>().toEqualTypeOf<PaymentService>();
|
||||
});
|
||||
|
||||
it("PaymentIntentStore + OwnedIntent shapes are exported", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expectTypeOf<OwnedIntent>().toHaveProperty("ownerId");
|
||||
expectTypeOf<PaymentIntentStore>().toHaveProperty("get");
|
||||
expectTypeOf<PaymentIntentStore>().toHaveProperty("put");
|
||||
expectTypeOf<PaymentIntentStore>().toHaveProperty("listByOwner");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Stable error-code registry for `@suaveplan/api-plugin-payment`. Every failure path in the plugin raises a `PaymentPluginError` that carries one of 15 `PAYMENT_PLUGIN.*` codes. The registry gives consumers a small, well-documented enum to programmatically branch on without inspecting human-readable messages — exactly the contract every other `@suaveplan/api-plugin-*` package follows.
|
||||
|
||||
The module also exports the `PaymentPluginError` class, which extends `SuaveplanError<PaymentPluginErrorCode>` so `instanceof PaymentPluginError` narrows `code` to the typed enum at the call site. The class preserves the supplied code on the discriminant `pluginCode` field for compatibility with downstream error handlers that match on a package-specific property name (the auth plugin uses `authCode`, the openapi plugin uses `openapiCode` — this package uses `pluginCode`).
|
||||
|
||||
## Features
|
||||
|
||||
- 15 namespaced codes under `PAYMENT_PLUGIN.` covering input validation, ownership/anti-enumeration, provider failures, webhook signature/timestamp, idempotency conflict, state-machine, amount/currency mismatch, refund-exceeds-captured, configuration, and service-registry plumbing.
|
||||
- Typed `PaymentPluginErrorContext` carrying `statusCode`, `intentId`, `ownerId`, `missing[]`, `prefix`, `currency`, `expectedCurrency` for diagnostic plumbing.
|
||||
- `PaymentPluginError` discriminant class with `pluginCode` and the standard `SuaveplanError` cause-chain, `toJSON`, `toProblemJSON`, and OTel span-event hooks.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
PAYMENT_PLUGIN_CODES,
|
||||
PaymentPluginError,
|
||||
} from "@suaveplan/api-plugin-payment";
|
||||
|
||||
throw new PaymentPluginError(
|
||||
PAYMENT_PLUGIN_CODES.NOT_FOUND,
|
||||
'Payment intent "abc" not found',
|
||||
{ context: { statusCode: 404, intentId: "abc" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
try {
|
||||
await service.captureIntent({ id, ownerId, amount });
|
||||
} catch (err) {
|
||||
if (err instanceof PaymentPluginError) {
|
||||
switch (err.pluginCode) {
|
||||
case PAYMENT_PLUGIN_CODES.NOT_FOUND:
|
||||
return c.json({ error: "not_found" }, 404);
|
||||
case PAYMENT_PLUGIN_CODES.CURRENCY_MISMATCH:
|
||||
case PAYMENT_PLUGIN_CODES.AMOUNT_MISMATCH:
|
||||
return c.json({ error: err.pluginCode }, 422);
|
||||
case PAYMENT_PLUGIN_CODES.IDEMPOTENCY_CONFLICT:
|
||||
return c.json({ error: "conflict" }, 409);
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PAYMENT_PLUGIN_CODES` — `defineCodes("PAYMENT_PLUGIN", [...])` const-object.
|
||||
- `PaymentPluginErrorCode` — union of the 15 `PAYMENT_PLUGIN.*` strings.
|
||||
- `PaymentPluginErrorContext` — optional discriminant fields carried alongside the base `SuaveplanError` context.
|
||||
- `PaymentPluginError extends SuaveplanError<PaymentPluginErrorCode>` — `pluginCode: PaymentPluginErrorCode`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The 15-code count matches the proposal's spec delta. Removing a code (because no production call site currently uses it) is banned by `AGENTS.md §2c` — codes are part of the public API contract and unused-looking codes get wired into call sites that didn't exist yet (e.g. `PROVIDER_NOT_CONFIGURED` covers a programmatic mis-wire pattern callers do hit during initial integration). The conformance test in `api-plugin-payment.spec-conformance.test.ts` proves every code is reachable from a non-test call site.
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
PAYMENT_PLUGIN_CODES,
|
||||
PaymentPluginError,
|
||||
type PaymentPluginErrorCode,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("PAYMENT_PLUGIN_CODES", () => {
|
||||
it("defines exactly 15 stable codes", { timeout: 5000 }, () => {
|
||||
const codes = Object.values(PAYMENT_PLUGIN_CODES);
|
||||
expect(codes).toHaveLength(15);
|
||||
});
|
||||
|
||||
it("namespaces every code under PAYMENT_PLUGIN", { timeout: 5000 }, () => {
|
||||
for (const code of Object.values(PAYMENT_PLUGIN_CODES)) {
|
||||
expect(code.startsWith("PAYMENT_PLUGIN.")).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaymentPluginError", () => {
|
||||
it("preserves the originally-supplied code on pluginCode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new PaymentPluginError(
|
||||
PAYMENT_PLUGIN_CODES.NOT_FOUND,
|
||||
"missing",
|
||||
{ context: { statusCode: 404 } }
|
||||
);
|
||||
expect(err.pluginCode).toBe(PAYMENT_PLUGIN_CODES.NOT_FOUND);
|
||||
expect(err.code).toBe(PAYMENT_PLUGIN_CODES.NOT_FOUND);
|
||||
expect(err).toBeInstanceOf(PaymentPluginError);
|
||||
});
|
||||
|
||||
it("narrows the code type via instanceof", { timeout: 5000 }, () => {
|
||||
const err: Error = new PaymentPluginError(
|
||||
PAYMENT_PLUGIN_CODES.INVALID_INPUT,
|
||||
"bad"
|
||||
);
|
||||
if (err instanceof PaymentPluginError) {
|
||||
const code: PaymentPluginErrorCode = err.pluginCode;
|
||||
expect(typeof code).toBe("string");
|
||||
} else {
|
||||
throw new Error("expected PaymentPluginError instance");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-plugin-payment`.
|
||||
*
|
||||
* Every failure path in the payment plugin raises a {@link
|
||||
* PaymentPluginError} carrying one of these `PAYMENT_PLUGIN.*` codes.
|
||||
* Consumers branch on the string value (e.g.
|
||||
* `error.code === PAYMENT_PLUGIN_CODES.NOT_FOUND`).
|
||||
*
|
||||
* - `INVALID_INPUT` — Zod-level boundary validation failed (request body,
|
||||
* query, params, or factory options). `cause` is the `ZodError`.
|
||||
* - `NOT_FOUND` — intent does not exist OR the caller is not the owner
|
||||
* (anti-enumeration — A foreign intent and a missing intent are
|
||||
* indistinguishable to the caller).
|
||||
* - `FORBIDDEN` — authenticated, but the principal lacks the permission
|
||||
* required for an admin-flavoured surface (reserved for future use:
|
||||
* provider re-sync, manual webhook replay).
|
||||
* - `PROVIDER_ERROR` — the underlying `PaymentProvider` raised an error
|
||||
* that was not a known categorised failure (`AMOUNT_MISMATCH` etc.).
|
||||
* - `WEBHOOK_SIGNATURE_INVALID` — webhook HMAC verification rejected the
|
||||
* request before deserialisation.
|
||||
* - `WEBHOOK_TIMESTAMP_INVALID` — webhook timestamp outside the
|
||||
* tolerance window (replay protection).
|
||||
* - `IDEMPOTENCY_CONFLICT` — same `Idempotency-Key` reused with a
|
||||
* different request body.
|
||||
* - `INVALID_STATE` — operation issued against an intent in a status
|
||||
* that does not permit it (e.g. capture-after-refund).
|
||||
* - `AMOUNT_MISMATCH` — submitted amount disagrees with the intent's
|
||||
* captured amount (refund / capture).
|
||||
* - `CURRENCY_MISMATCH` — submitted currency disagrees with the
|
||||
* intent's currency.
|
||||
* - `REFUND_EXCEEDS_CAPTURED` — refund total would exceed the
|
||||
* intent's captured balance.
|
||||
* - `PROVIDER_NOT_CONFIGURED` — `createPaymentPlugin` was called
|
||||
* without a `provider`. (Reserved — the factory normally rejects via
|
||||
* `INVALID_INPUT`; the dedicated code lets downstream callers
|
||||
* distinguish a programmatic mis-wire from a Zod failure.)
|
||||
* - `INTENT_CREATION_FAILED` — the provider returned without raising
|
||||
* but produced no `PaymentIntent` (defensive — guards against
|
||||
* adapters that swallow upstream failures).
|
||||
* - `SERVICE_NOT_AVAILABLE` — a required `ServiceRegistry` slot
|
||||
* (`app`) was absent at `onInit` time.
|
||||
* - `ROUTE_PREFIX_COLLISION` — `routePrefix` overlapped with routes
|
||||
* already registered by another plugin.
|
||||
*/
|
||||
export const PAYMENT_PLUGIN_CODES = defineCodes("PAYMENT_PLUGIN", [
|
||||
"INVALID_INPUT",
|
||||
"NOT_FOUND",
|
||||
"FORBIDDEN",
|
||||
"PROVIDER_ERROR",
|
||||
"WEBHOOK_SIGNATURE_INVALID",
|
||||
"WEBHOOK_TIMESTAMP_INVALID",
|
||||
"IDEMPOTENCY_CONFLICT",
|
||||
"INVALID_STATE",
|
||||
"AMOUNT_MISMATCH",
|
||||
"CURRENCY_MISMATCH",
|
||||
"REFUND_EXCEEDS_CAPTURED",
|
||||
"PROVIDER_NOT_CONFIGURED",
|
||||
"INTENT_CREATION_FAILED",
|
||||
"SERVICE_NOT_AVAILABLE",
|
||||
"ROUTE_PREFIX_COLLISION",
|
||||
] as const);
|
||||
|
||||
export type PaymentPluginErrorCode =
|
||||
(typeof PAYMENT_PLUGIN_CODES)[keyof typeof PAYMENT_PLUGIN_CODES];
|
||||
|
||||
/**
|
||||
* Discriminant context carried on a `PaymentPluginError`. All fields
|
||||
* are optional — populate the ones that pertain to the failure so log
|
||||
* lines and Problem+JSON envelopes carry actionable diagnostic data.
|
||||
*/
|
||||
export interface PaymentPluginErrorContext {
|
||||
readonly statusCode?: number;
|
||||
readonly intentId?: string;
|
||||
readonly ownerId?: string;
|
||||
readonly missing?: readonly string[];
|
||||
readonly prefix?: string;
|
||||
readonly currency?: string;
|
||||
readonly expectedCurrency?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concrete error class for the api-plugin-payment package. Extends
|
||||
* {@link SuaveplanError} so `instanceof PaymentPluginError` narrows
|
||||
* `code` to {@link PaymentPluginErrorCode}.
|
||||
*/
|
||||
export class PaymentPluginError extends SuaveplanError<PaymentPluginErrorCode> {
|
||||
public readonly pluginCode: PaymentPluginErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: PaymentPluginErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.pluginCode = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
PaymentPluginErrorCode,
|
||||
PaymentPluginErrorContext,
|
||||
} from "./error-codes.js";
|
||||
export {
|
||||
PAYMENT_PLUGIN_CODES,
|
||||
PaymentPluginError,
|
||||
} from "./error-codes.js";
|
||||
104
packages/api-stack/api/api-plugin-payment/src/index.ts
Normal file
104
packages/api-stack/api/api-plugin-payment/src/index.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// Side-effect imports — surface the registry + context augmentations to
|
||||
// every consumer importing the package barrel.
|
||||
import "./middleware/context-augmentation.js";
|
||||
import "./services/registry-augmentation.js";
|
||||
|
||||
// Error codes
|
||||
export {
|
||||
PAYMENT_PLUGIN_CODES,
|
||||
PaymentPluginError,
|
||||
type PaymentPluginErrorCode,
|
||||
type PaymentPluginErrorContext,
|
||||
} from "./error-codes/error-codes.js";
|
||||
|
||||
// Middleware
|
||||
export {
|
||||
type CreatePaymentIdempotencyMiddlewareOptions,
|
||||
createPaymentIdempotencyMiddleware,
|
||||
type PaymentIdempotencyStore,
|
||||
} from "./middleware/idempotency-middleware.js";
|
||||
|
||||
// OTel instruments
|
||||
export {
|
||||
getPaymentInstruments,
|
||||
type PaymentInstruments,
|
||||
resetPaymentInstruments,
|
||||
} from "./otel/instruments.js";
|
||||
|
||||
// Plugin factory
|
||||
export { createPaymentPlugin } from "./plugin/plugin.js";
|
||||
|
||||
// Routes
|
||||
export {
|
||||
type BuildPaymentRoutesOptions,
|
||||
buildPaymentRoutes,
|
||||
} from "./routes/build-routes.js";
|
||||
|
||||
// Schemas
|
||||
export { type Money, MoneySchema } from "./schemas/money.js";
|
||||
export {
|
||||
type PaymentPluginOptionsInput,
|
||||
type PaymentPluginOptionsResolved,
|
||||
PaymentPluginOptionsSchema,
|
||||
} from "./schemas/options.js";
|
||||
export {
|
||||
type CaptureBody,
|
||||
CaptureBodySchema,
|
||||
type CreatePaymentIntentBody,
|
||||
CreatePaymentIntentBodySchema,
|
||||
type EmptyBody,
|
||||
EmptyBodySchema,
|
||||
type ListPaymentIntentsQuery,
|
||||
ListPaymentIntentsQuerySchema,
|
||||
type PaymentIntentEnvelope,
|
||||
type PaymentIntentIdParam,
|
||||
PaymentIntentIdParamSchema,
|
||||
type PaymentIntentListResponse,
|
||||
PaymentIntentListResponseSchema,
|
||||
type PaymentIntentResponse,
|
||||
PaymentIntentResponseSchema,
|
||||
PaymentIntentSchema,
|
||||
type PaymentStatusName,
|
||||
PaymentStatusSchema,
|
||||
type RefundBody,
|
||||
RefundBodySchema,
|
||||
type RefundEnvelope,
|
||||
type RefundResponse,
|
||||
RefundResponseSchema,
|
||||
RefundSchema,
|
||||
type WebhookResponse,
|
||||
WebhookResponseSchema,
|
||||
} from "./schemas/routes/index.js";
|
||||
|
||||
// Services
|
||||
export {
|
||||
type CaptureArgs,
|
||||
type CreateIntentArgs,
|
||||
type CreatePaymentServiceOptions,
|
||||
createPaymentService,
|
||||
type IntentByIdArgs,
|
||||
type OwnerScopedArgs,
|
||||
type PaymentService,
|
||||
type RefundArgs,
|
||||
} from "./services/payment-service.js";
|
||||
|
||||
// Store
|
||||
export { MemoryPaymentIntentStore } from "./store/memory-store.js";
|
||||
export type {
|
||||
OwnedIntent,
|
||||
PaymentIntentStore,
|
||||
} from "./store/payment-intent-store.js";
|
||||
|
||||
// Types
|
||||
export type {
|
||||
PaymentPluginConfig,
|
||||
PaymentPluginOptions,
|
||||
} from "./types/types.js";
|
||||
|
||||
// Webhook
|
||||
export {
|
||||
signPaymentWebhook,
|
||||
type VerifyPaymentWebhookOptions,
|
||||
verifyPaymentWebhook,
|
||||
type WebhookAlgorithmName,
|
||||
} from "./webhook/verify-webhook.js";
|
||||
@@ -0,0 +1,47 @@
|
||||
# context-augmentation
|
||||
|
||||
## Purpose
|
||||
|
||||
TypeScript module augmentation that surfaces the Hono `ContextVariableMap` slot `user: Principal | null` to every consumer of `@suaveplan/api-plugin-payment`. The auth plugin already declares this slot in its own module augmentation; we redeclare it here as a cumulative augmentation so this package's route handlers and tests can `c.get("user")` with proper typing without forcing every test file to side-effect-import the auth package's barrel.
|
||||
|
||||
TypeScript merges duplicate interface declarations, so coexistence with `@suaveplan/api-plugin-auth/middleware/context-augmentation` is safe and idiomatic. The pattern mirrors what `@suaveplan/api-plugin-auth` does with the `ServiceRegistry` interface — declare what you need, let TypeScript merge with everyone else's declarations.
|
||||
|
||||
## Features
|
||||
|
||||
- Adds `user: Principal | null` to Hono's `ContextVariableMap`.
|
||||
- Pure type-level — zero runtime cost.
|
||||
- Imported as a side-effect from `src/index.ts`, `src/middleware/idempotency-middleware.ts`, and `src/routes/build-routes.ts` so every entry point that touches the Hono context surfaces the augmentation transitively.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
// In any source / test file:
|
||||
import "@suaveplan/api-plugin-payment/middleware/idempotency-middleware";
|
||||
// or
|
||||
import "@suaveplan/api-plugin-payment";
|
||||
|
||||
app.use(async (c, next) => {
|
||||
const principal = c.get("user"); // typed Principal | null
|
||||
await next();
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
// Tests construct their own Hono app and set the principal directly:
|
||||
const app = new Hono();
|
||||
app.use(async (c, next) => {
|
||||
c.set("user", { id: "u1", emailVerified: true, roles: [] });
|
||||
await next();
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- Pure module-augmentation contribution; no runtime exports.
|
||||
- Adds `ContextVariableMap.user: Principal | null`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The augmentation imports `Principal` from `@suaveplan/api-plugin-auth` (the canonical source) so the type stays in lock-step with the auth plugin's evolution. Defining `Principal` here independently would split the contract and force every downstream consumer to track two different `Principal` shapes; re-exporting via module augmentation keeps the single canonical type.
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Hono `ContextVariableMap` augmentation surfaced from
|
||||
* `@suaveplan/api-plugin-auth`. Re-declared here so this package's
|
||||
* routes (`requireAuth`-gated) and its tests see `c.get("user")` typed
|
||||
* as `Principal | null` without forcing every test file to side-effect-
|
||||
* import the auth package's barrel. TypeScript merges duplicate
|
||||
* declarations so coexistence with the auth plugin's own declaration
|
||||
* is safe.
|
||||
*/
|
||||
import type { Principal } from "@suaveplan/api-plugin-auth";
|
||||
|
||||
declare module "hono" {
|
||||
interface ContextVariableMap {
|
||||
user: Principal | null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# idempotency-middleware
|
||||
|
||||
## Purpose
|
||||
|
||||
Hono middleware enforcing the `Idempotency-Key` header on payment-mutation routes (`POST /payments/intents`, `POST /payments/intents/:id/refund`). Idempotency on mutation routes is non-negotiable for payments — a network retry after a successful charge would double-bill the customer, an outcome that ends in chargebacks, compliance reports, and customer churn. The middleware uses the principal id (`c.get("user")!.id`) as a scope so the same key value sent by different users does not collide.
|
||||
|
||||
When the header is present and the `(ownerId, key)` pair has a recorded response, the middleware replays the captured `(status, body, content-type)` triple verbatim and skips the handler. When the same key is reused with a different body, the middleware raises `PaymentPluginError(IDEMPOTENCY_CONFLICT)` (HTTP 409). When the header is absent, the middleware is a no-op.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-principal scoping (`ownerId:key`) so keys never collide across users.
|
||||
- Body fingerprinting normalises key order in JSON objects so `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` match.
|
||||
- Pluggable `PaymentIdempotencyStore` — defaults to the `MemoryIdempotencyStore` from `@suaveplan/idempotency`.
|
||||
- Configurable header name (`Idempotency-Key` default), TTL (24h default), and `Clock`.
|
||||
- Re-feeds the consumed body to the handler via `new Request(...)` so downstream `c.req.json()` reads the same bytes.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createPaymentIdempotencyMiddleware } from "@suaveplan/api-plugin-payment/middleware/idempotency-middleware";
|
||||
|
||||
app.use("/payments/intents", createPaymentIdempotencyMiddleware());
|
||||
app.use("/payments/intents/:id/refund", createPaymentIdempotencyMiddleware());
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createPaymentIdempotencyMiddleware } from "@suaveplan/api-plugin-payment/middleware/idempotency-middleware";
|
||||
import { RedisIdempotencyStore } from "@suaveplan/idempotency";
|
||||
|
||||
const store = new RedisIdempotencyStore({ client: redisClient });
|
||||
const middleware = createPaymentIdempotencyMiddleware({
|
||||
store: store as never, // adapter shape matches the PaymentIdempotencyStore contract
|
||||
headerName: "X-Idempotency-Token",
|
||||
ttlMs: 7 * 24 * 60 * 60 * 1000, // 1 week
|
||||
clock: { now: () => clockSource.nowMs() },
|
||||
});
|
||||
app.use("/payments/intents", middleware);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createPaymentIdempotencyMiddleware(options?): MiddlewareHandler`.
|
||||
- `CreatePaymentIdempotencyMiddlewareOptions` — `{ store?, headerName?, ttlMs?, clock? }`.
|
||||
- `PaymentIdempotencyStore` — `{ get(key), set(key, record) }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The middleware consumes the request body with `c.req.text()` and reconstructs `c.req.raw` via `new Request(...)` so subsequent `c.req.json()` calls in the handler read the same bytes. This preserves a single-pass body-reading contract while still letting the middleware compute a fingerprint. Caching `c.res.clone().text()` (rather than the original `c.res`) keeps the response stream available to Hono's serialiser — cloning is cheap because `Response.clone()` shares the underlying body stream.
|
||||
|
||||
The TTL default is 24 hours, matching Stripe's recommended client-retry window. Operators wiring a persistent store (Redis, SurrealDB) should align the TTL with their store's eviction policy so cached responses aren't expired faster than the underlying records.
|
||||
@@ -0,0 +1,377 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
PAYMENT_PLUGIN_CODES,
|
||||
PaymentPluginError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
createPaymentIdempotencyMiddleware,
|
||||
type PaymentIdempotencyStore,
|
||||
} from "./idempotency-middleware.js";
|
||||
|
||||
function makeApp(
|
||||
options: Parameters<typeof createPaymentIdempotencyMiddleware>[0] = {}
|
||||
) {
|
||||
const calls = { count: 0 };
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u1", emailVerified: true, roles: [] });
|
||||
await next();
|
||||
});
|
||||
app.use("*", createPaymentIdempotencyMiddleware(options));
|
||||
app.post("/op", async (c) => {
|
||||
calls.count += 1;
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
return c.json({ ok: true, echo: body, callCount: calls.count }, 201);
|
||||
});
|
||||
return { app, calls };
|
||||
}
|
||||
|
||||
describe("createPaymentIdempotencyMiddleware", () => {
|
||||
it("is a no-op when the header is absent", { timeout: 5000 }, async () => {
|
||||
const { app, calls } = makeApp();
|
||||
const a = await app.request("/op", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ x: 1 }),
|
||||
});
|
||||
const b = await app.request("/op", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ x: 1 }),
|
||||
});
|
||||
expect(a.status).toBe(201);
|
||||
expect(b.status).toBe(201);
|
||||
expect(calls.count).toBe(2);
|
||||
});
|
||||
|
||||
it("replays the cached response on the second identical call", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app, calls } = makeApp();
|
||||
const headers = {
|
||||
"content-type": "application/json",
|
||||
"Idempotency-Key": "k1",
|
||||
};
|
||||
const a = await app.request("/op", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ x: 1 }),
|
||||
});
|
||||
const aBody = await a.text();
|
||||
const b = await app.request("/op", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ x: 1 }),
|
||||
});
|
||||
const bBody = await b.text();
|
||||
expect(calls.count).toBe(1);
|
||||
expect(b.status).toBe(201);
|
||||
expect(bBody).toBe(aBody);
|
||||
});
|
||||
|
||||
it("raises IDEMPOTENCY_CONFLICT when body differs under same key", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// The middleware throws PaymentPluginError on conflict; Hono's
|
||||
// onError handler catches it and returns the configured status.
|
||||
// We register an explicit error handler that proves the
|
||||
// PaymentPluginError surfaced.
|
||||
const { app } = makeApp();
|
||||
let captured: PaymentPluginError | undefined;
|
||||
app.onError((err, c) => {
|
||||
if (err instanceof PaymentPluginError) {
|
||||
captured = err;
|
||||
return c.json(
|
||||
{ error: { code: err.code } },
|
||||
err.context?.["statusCode"] === 409 ? 409 : 500
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
const headers = {
|
||||
"content-type": "application/json",
|
||||
"Idempotency-Key": "k1",
|
||||
};
|
||||
await app.request("/op", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ x: 1 }),
|
||||
});
|
||||
const res = await app.request("/op", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ x: 2 }),
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
expect(captured).toBeInstanceOf(PaymentPluginError);
|
||||
expect(captured?.pluginCode).toBe(
|
||||
PAYMENT_PLUGIN_CODES.IDEMPOTENCY_CONFLICT
|
||||
);
|
||||
});
|
||||
|
||||
it("fingerprints body shape independent of key order", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app, calls } = makeApp();
|
||||
const headers = {
|
||||
"content-type": "application/json",
|
||||
"Idempotency-Key": "k1",
|
||||
};
|
||||
await app.request("/op", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ a: 1, b: 2 }),
|
||||
});
|
||||
await app.request("/op", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ b: 2, a: 1 }),
|
||||
});
|
||||
expect(calls.count).toBe(1);
|
||||
});
|
||||
|
||||
it("fingerprints arrays without normalising element order", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app, calls } = makeApp();
|
||||
const headers = {
|
||||
"content-type": "application/json",
|
||||
"Idempotency-Key": "k-arr",
|
||||
};
|
||||
await app.request("/op", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ items: [1, 2, 3] }),
|
||||
});
|
||||
await app.request("/op", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ items: [1, 2, 3] }),
|
||||
});
|
||||
expect(calls.count).toBe(1);
|
||||
});
|
||||
|
||||
it("falls back to '_anonymous' when no principal is present", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const records = new Map<
|
||||
string,
|
||||
Parameters<PaymentIdempotencyStore["set"]>[1]
|
||||
>();
|
||||
const store: PaymentIdempotencyStore = {
|
||||
async get(key) {
|
||||
return records.get(key);
|
||||
},
|
||||
async set(key, record) {
|
||||
records.set(key, record);
|
||||
},
|
||||
};
|
||||
const app = new Hono();
|
||||
app.use("*", createPaymentIdempotencyMiddleware({ store }));
|
||||
app.post("/op", async (c) => c.json({ ok: true }));
|
||||
await app.request("/op", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"Idempotency-Key": "no-principal",
|
||||
},
|
||||
body: JSON.stringify({ x: 1 }),
|
||||
});
|
||||
const keys = [...records.keys()];
|
||||
expect(keys[0]?.startsWith("_anonymous:")).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to application/json when response has no content-type", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Drive the middleware directly so we can fully control the
|
||||
// response object — Response constructed from a string in browsers/Bun
|
||||
// auto-adds `text/plain` which masks the fallback branch.
|
||||
const records = new Map<
|
||||
string,
|
||||
Parameters<PaymentIdempotencyStore["set"]>[1]
|
||||
>();
|
||||
const store: PaymentIdempotencyStore = {
|
||||
async get(key) {
|
||||
return records.get(key);
|
||||
},
|
||||
async set(key, record) {
|
||||
records.set(key, record);
|
||||
},
|
||||
};
|
||||
const middleware = createPaymentIdempotencyMiddleware({ store });
|
||||
const req = new Request("http://x/op", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"Idempotency-Key": "k-no-ct",
|
||||
},
|
||||
body: JSON.stringify({ x: 1 }),
|
||||
});
|
||||
const noCtResponse = new Response(null, { status: 204 });
|
||||
const c = {
|
||||
req: {
|
||||
raw: req,
|
||||
header: (n: string) => req.headers.get(n) ?? undefined,
|
||||
text: async () => req.clone().text(),
|
||||
},
|
||||
get: () => ({ id: "u1", emailVerified: true, roles: [] }),
|
||||
res: noCtResponse,
|
||||
} as never;
|
||||
await middleware(c, async () => undefined);
|
||||
const stored = [...records.values()][0];
|
||||
const cached = stored?.response as { contentType: string };
|
||||
expect(cached.contentType).toBe("application/json");
|
||||
});
|
||||
|
||||
it("treats empty body POSTs as fingerprintable", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app, calls } = makeApp();
|
||||
const headers = { "Idempotency-Key": "k1" };
|
||||
await app.request("/op", { method: "POST", headers });
|
||||
await app.request("/op", { method: "POST", headers });
|
||||
expect(calls.count).toBe(1);
|
||||
});
|
||||
|
||||
it("treats non-JSON bodies as raw-string fingerprints", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app, calls } = makeApp();
|
||||
const headers = {
|
||||
"content-type": "text/plain",
|
||||
"Idempotency-Key": "k1",
|
||||
};
|
||||
await app.request("/op", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: "not json",
|
||||
});
|
||||
await app.request("/op", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: "not json",
|
||||
});
|
||||
expect(calls.count).toBe(1);
|
||||
});
|
||||
|
||||
it("uses the principal id from c.get('user') for scoping", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app: appA, calls: callsA } = makeApp();
|
||||
const headers = {
|
||||
"content-type": "application/json",
|
||||
"Idempotency-Key": "shared-key",
|
||||
};
|
||||
await appA.request("/op", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ x: 1 }),
|
||||
});
|
||||
await appA.request("/op", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ x: 1 }),
|
||||
});
|
||||
expect(callsA.count).toBe(1);
|
||||
|
||||
// Same key but a different principal scopes separately
|
||||
const app2 = new Hono();
|
||||
const calls = { count: 0 };
|
||||
app2.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u-other", emailVerified: true, roles: [] });
|
||||
await next();
|
||||
});
|
||||
app2.use("*", createPaymentIdempotencyMiddleware());
|
||||
app2.post("/op", async (c) => {
|
||||
calls.count += 1;
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
await app2.request("/op", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ x: 1 }),
|
||||
});
|
||||
expect(calls.count).toBe(1);
|
||||
});
|
||||
|
||||
it("honours a caller-supplied store", { timeout: 5000 }, async () => {
|
||||
const reads: string[] = [];
|
||||
const writes: string[] = [];
|
||||
const records = new Map<
|
||||
string,
|
||||
Parameters<PaymentIdempotencyStore["set"]>[1]
|
||||
>();
|
||||
const store: PaymentIdempotencyStore = {
|
||||
async get(key) {
|
||||
reads.push(key);
|
||||
return records.get(key);
|
||||
},
|
||||
async set(key, record) {
|
||||
writes.push(key);
|
||||
records.set(key, record);
|
||||
},
|
||||
};
|
||||
const { app } = makeApp({ store });
|
||||
const headers = {
|
||||
"content-type": "application/json",
|
||||
"Idempotency-Key": "k-custom",
|
||||
};
|
||||
await app.request("/op", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ x: 1 }),
|
||||
});
|
||||
await app.request("/op", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ x: 1 }),
|
||||
});
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(reads.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("respects a custom header name", { timeout: 5000 }, async () => {
|
||||
const { app, calls } = makeApp({ headerName: "X-Idem-Key" });
|
||||
const headers = {
|
||||
"content-type": "application/json",
|
||||
"X-Idem-Key": "k1",
|
||||
};
|
||||
await app.request("/op", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ x: 1 }),
|
||||
});
|
||||
await app.request("/op", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ x: 1 }),
|
||||
});
|
||||
expect(calls.count).toBe(1);
|
||||
});
|
||||
|
||||
it("respects a custom clock + ttl", { timeout: 5000 }, async () => {
|
||||
let t = 1_000;
|
||||
const { app, calls } = makeApp({
|
||||
clock: () => t,
|
||||
ttlMs: 10,
|
||||
});
|
||||
const headers = {
|
||||
"content-type": "application/json",
|
||||
"Idempotency-Key": "k-ttl",
|
||||
};
|
||||
await app.request("/op", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ x: 1 }),
|
||||
});
|
||||
t += 100; // expire
|
||||
await app.request("/op", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ x: 1 }),
|
||||
});
|
||||
expect(calls.count).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { MemoryIdempotencyStore } from "@suaveplan/idempotency";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import {
|
||||
PAYMENT_PLUGIN_CODES,
|
||||
PaymentPluginError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import "./context-augmentation.js";
|
||||
|
||||
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Adapter shape — any compatible IdempotencyStoreAdapter works. */
|
||||
export interface PaymentIdempotencyStore {
|
||||
get(key: string): Promise<
|
||||
| {
|
||||
readonly key: string;
|
||||
readonly fingerprint: string;
|
||||
readonly response: unknown;
|
||||
readonly createdAt: number;
|
||||
readonly expiresAt: number;
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
set(
|
||||
key: string,
|
||||
record: {
|
||||
readonly key: string;
|
||||
readonly fingerprint: string;
|
||||
readonly response: unknown;
|
||||
readonly createdAt: number;
|
||||
readonly expiresAt: number;
|
||||
}
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
/** Options accepted by {@link createPaymentIdempotencyMiddleware}. */
|
||||
export interface CreatePaymentIdempotencyMiddlewareOptions {
|
||||
readonly store?: PaymentIdempotencyStore;
|
||||
readonly headerName?: string;
|
||||
readonly ttlMs?: number;
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a deterministic content-fingerprint for a request body. JSON
|
||||
* stringification with sorted top-level keys keeps `{ a: 1, b: 2 }` and
|
||||
* `{ b: 2, a: 1 }` from drifting apart in the fingerprint. Bigints are
|
||||
* coerced to their decimal string form.
|
||||
*/
|
||||
function fingerprintBody(body: unknown): string {
|
||||
return JSON.stringify(body, (_key, value) => {
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value)
|
||||
) {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of Object.keys(value).sort()) {
|
||||
out[k] = (value as Record<string, unknown>)[k];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hono middleware enforcing the `Idempotency-Key` header on mutation
|
||||
* routes (`POST /payments/intents`, `POST /payments/intents/:id/refund`).
|
||||
*
|
||||
* Behaviour:
|
||||
*
|
||||
* - When the request omits the header, the middleware is a no-op and
|
||||
* the route proceeds normally.
|
||||
* - When the header is present and a record exists for `(ownerId,
|
||||
* key)`:
|
||||
* - identical body → replay the cached `(status, body)` response
|
||||
* verbatim, do NOT call the handler.
|
||||
* - different body → throw `PaymentPluginError(IDEMPOTENCY_CONFLICT)`
|
||||
* (HTTP 409).
|
||||
* - When the header is present and no record exists: invoke the
|
||||
* handler, then capture the response status + body and record it
|
||||
* against the key for `ttlMs` (default 24h).
|
||||
*/
|
||||
export function createPaymentIdempotencyMiddleware(
|
||||
options: CreatePaymentIdempotencyMiddlewareOptions = {}
|
||||
): MiddlewareHandler {
|
||||
const store =
|
||||
options.store ??
|
||||
(new MemoryIdempotencyStore() as unknown as PaymentIdempotencyStore);
|
||||
const headerName = options.headerName ?? "Idempotency-Key";
|
||||
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
||||
const clock: Clock = options.clock ?? (() => Date.now());
|
||||
|
||||
return async function paymentIdempotency(c, next) {
|
||||
const key = c.req.header(headerName);
|
||||
if (key === undefined || key.length === 0) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
const principal = c.get("user");
|
||||
const ownerId = principal?.id ?? "_anonymous";
|
||||
const compositeKey = `${ownerId}:${key}`;
|
||||
|
||||
const rawBody = await c.req.text();
|
||||
let bodyParsed: unknown = null;
|
||||
if (rawBody.length > 0) {
|
||||
try {
|
||||
bodyParsed = JSON.parse(rawBody);
|
||||
} catch {
|
||||
bodyParsed = rawBody;
|
||||
}
|
||||
}
|
||||
const fingerprint = fingerprintBody(bodyParsed);
|
||||
|
||||
const cached = await store.get(compositeKey);
|
||||
if (cached !== undefined) {
|
||||
if (cached.fingerprint !== fingerprint) {
|
||||
throw new PaymentPluginError(
|
||||
PAYMENT_PLUGIN_CODES.IDEMPOTENCY_CONFLICT,
|
||||
`Idempotency-Key "${key}" reused with a different request body`,
|
||||
{ context: { statusCode: 409 } }
|
||||
);
|
||||
}
|
||||
const cachedResponse = cached.response as {
|
||||
readonly status: number;
|
||||
readonly body: string;
|
||||
readonly contentType: string;
|
||||
};
|
||||
return new Response(cachedResponse.body, {
|
||||
status: cachedResponse.status,
|
||||
headers: { "content-type": cachedResponse.contentType },
|
||||
});
|
||||
}
|
||||
|
||||
// Re-feed the consumed body to the handler. Replace the underlying
|
||||
// request so downstream `c.req.json()` reads the same bytes.
|
||||
c.req.raw = new Request(c.req.raw.url, {
|
||||
method: c.req.raw.method,
|
||||
headers: c.req.raw.headers,
|
||||
body: rawBody.length > 0 ? rawBody : null,
|
||||
});
|
||||
|
||||
await next();
|
||||
|
||||
const res = c.res;
|
||||
const responseBody = await res.clone().text();
|
||||
const contentType =
|
||||
res.headers.get("content-type") ?? "application/json";
|
||||
const now = clock();
|
||||
await store.set(compositeKey, {
|
||||
key: compositeKey,
|
||||
fingerprint,
|
||||
response: {
|
||||
status: res.status,
|
||||
body: responseBody,
|
||||
contentType,
|
||||
},
|
||||
createdAt: now,
|
||||
expiresAt: now + ttlMs,
|
||||
});
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type CreatePaymentIdempotencyMiddlewareOptions,
|
||||
createPaymentIdempotencyMiddleware,
|
||||
type PaymentIdempotencyStore,
|
||||
} from "./idempotency-middleware.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
getPaymentInstruments,
|
||||
type PaymentInstruments,
|
||||
resetPaymentInstruments,
|
||||
} from "./instruments.js";
|
||||
@@ -0,0 +1,58 @@
|
||||
# instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
OpenTelemetry instrument bundle for `@suaveplan/api-plugin-payment`. Exposes 5 counters and 1 histogram so operators can plot payment-lifecycle throughput, success/failure ratios, refund volume, webhook delivery rate, and intent-creation latency in their observability stack. Every metric carries a `provider` label so the same dashboard slices across Stripe / Adyen / PayPal / Square / Mollie deployments.
|
||||
|
||||
The bundle is built lazily against the global `MeterProvider` and cached so repeat calls return the same instrument handles. `resetPaymentInstruments()` clears the cache for tests that install a fresh `MeterProvider`.
|
||||
|
||||
## Features
|
||||
|
||||
- `payments.intents_created_total{outcome,provider}` — every intent-creation attempt.
|
||||
- `payments.captures_total{outcome,provider}` — every capture attempt.
|
||||
- `payments.refunds_total{outcome,provider}` — every refund attempt.
|
||||
- `payments.webhook_received_total{outcome,provider}` — every inbound webhook (accepted or rejected).
|
||||
- `payments.webhook_signature_failures_total{provider}` — dedicated counter for HMAC failures.
|
||||
- `payments.intent_duration_ms{provider}` — histogram of end-to-end intent-creation duration.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getPaymentInstruments } from "@suaveplan/api-plugin-payment/otel/instruments";
|
||||
|
||||
const i = getPaymentInstruments();
|
||||
const t0 = performance.now();
|
||||
const intent = await provider.createIntent(input);
|
||||
i.intentDurationMs.record(performance.now() - t0, { provider: provider.name });
|
||||
i.intentsCreatedTotal.add(1, { outcome: "success", provider: provider.name });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
getPaymentInstruments,
|
||||
resetPaymentInstruments,
|
||||
} from "@suaveplan/api-plugin-payment/otel/instruments";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
|
||||
// Inside a test that needs a fresh MeterProvider:
|
||||
metrics.setGlobalMeterProvider(new MeterProvider({ readers: [reader] }));
|
||||
resetPaymentInstruments();
|
||||
// Now subsequent getPaymentInstruments() binds against the new provider.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `getPaymentInstruments(): PaymentInstruments` — returns the cached bundle (or constructs it).
|
||||
- `resetPaymentInstruments(): void` — clears the cache.
|
||||
- `PaymentInstruments` — `{ intentsCreatedTotal, capturesTotal, refundsTotal, webhookReceivedTotal, webhookSignatureFailuresTotal, intentDurationMs }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The counters use the `outcome` label rather than separate counters per outcome to keep dashboard queries simple — `sum(rate(payments.intents_created_total[5m])) by (outcome, provider)` slices both axes in a single PromQL expression. The dedicated `payments.webhook_signature_failures_total` counter exists alongside the parameterised `payments.webhook_received_total` because operators commonly alert on the failure-only rate without having to filter on the `outcome` label.
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
|
||||
import {
|
||||
getPaymentInstruments,
|
||||
resetPaymentInstruments,
|
||||
} from "./instruments.js";
|
||||
|
||||
beforeEach(() => {
|
||||
resetPaymentInstruments();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await flushTestMetrics();
|
||||
});
|
||||
|
||||
describe("payment instruments", () => {
|
||||
it("caches the bundle across consecutive lookups", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = getPaymentInstruments();
|
||||
const b = getPaymentInstruments();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("resetPaymentInstruments forces a new bundle", { timeout: 5000 }, () => {
|
||||
const a = getPaymentInstruments();
|
||||
resetPaymentInstruments();
|
||||
const b = getPaymentInstruments();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("counter + histogram emit per intent create", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const instruments = getPaymentInstruments();
|
||||
instruments.intentsCreatedTotal.add(1, {
|
||||
outcome: "success",
|
||||
provider: "test",
|
||||
});
|
||||
instruments.intentDurationMs.record(12.5, { provider: "test" });
|
||||
const collected = await flushTestMetrics();
|
||||
const flat = collected.flatMap((c) =>
|
||||
c.scopeMetrics.flatMap((sm) => sm.metrics)
|
||||
);
|
||||
const names = flat.map((m) => m.descriptor.name);
|
||||
expect(names).toContain("payments.intents_created_total");
|
||||
expect(names).toContain("payments.intent_duration_ms");
|
||||
});
|
||||
|
||||
it("exposes all 5 counters + 1 histogram", { timeout: 5000 }, () => {
|
||||
const i = getPaymentInstruments();
|
||||
expect(typeof i.intentsCreatedTotal.add).toBe("function");
|
||||
expect(typeof i.capturesTotal.add).toBe("function");
|
||||
expect(typeof i.refundsTotal.add).toBe("function");
|
||||
expect(typeof i.webhookReceivedTotal.add).toBe("function");
|
||||
expect(typeof i.webhookSignatureFailuresTotal.add).toBe("function");
|
||||
expect(typeof i.intentDurationMs.record).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Bundle of OpenTelemetry instruments emitted by the payment plugin.
|
||||
*
|
||||
* Counter labels:
|
||||
* - `outcome ∈ "success" | "failure"` for `intentsCreated`,
|
||||
* `captures`, `refunds`.
|
||||
* - `outcome ∈ "accepted" | "rejected"` for `webhookReceived`.
|
||||
* - `provider` is the `PaymentProvider.name` (e.g. `"stripe"`,
|
||||
* `"paypal"`, `"adyen"`, `"square"`, `"mollie"`, or `"test"`).
|
||||
*/
|
||||
export interface PaymentInstruments {
|
||||
readonly intentsCreatedTotal: Counter;
|
||||
readonly capturesTotal: Counter;
|
||||
readonly refundsTotal: Counter;
|
||||
readonly webhookReceivedTotal: Counter;
|
||||
readonly webhookSignatureFailuresTotal: Counter;
|
||||
readonly intentDurationMs: Histogram;
|
||||
}
|
||||
|
||||
let cached: PaymentInstruments | undefined;
|
||||
|
||||
/**
|
||||
* Lazily build (and cache) the payment instruments bundle against the
|
||||
* current global `MeterProvider`. Call `resetPaymentInstruments()` in
|
||||
* test setup to force a re-bind against a freshly-installed provider.
|
||||
*/
|
||||
export function getPaymentInstruments(): PaymentInstruments {
|
||||
if (cached !== undefined) return cached;
|
||||
const meter = metrics.getMeter("@suaveplan/api-plugin-payment");
|
||||
cached = {
|
||||
intentsCreatedTotal: meter.createCounter(
|
||||
"payments.intents_created_total",
|
||||
{
|
||||
description:
|
||||
"Total payment-intent creation attempts, partitioned by outcome+provider",
|
||||
}
|
||||
),
|
||||
capturesTotal: meter.createCounter("payments.captures_total", {
|
||||
description:
|
||||
"Total payment-intent capture attempts, partitioned by outcome+provider",
|
||||
}),
|
||||
refundsTotal: meter.createCounter("payments.refunds_total", {
|
||||
description:
|
||||
"Total refund attempts, partitioned by outcome+provider",
|
||||
}),
|
||||
webhookReceivedTotal: meter.createCounter(
|
||||
"payments.webhook_received_total",
|
||||
{
|
||||
description:
|
||||
"Total webhook deliveries received, partitioned by outcome+provider",
|
||||
}
|
||||
),
|
||||
webhookSignatureFailuresTotal: meter.createCounter(
|
||||
"payments.webhook_signature_failures_total",
|
||||
{
|
||||
description:
|
||||
"Total webhook signature/timestamp verification failures, partitioned by provider",
|
||||
}
|
||||
),
|
||||
intentDurationMs: meter.createHistogram("payments.intent_duration_ms", {
|
||||
description: "Payment-intent creation duration in ms",
|
||||
unit: "ms",
|
||||
}),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached instrument bundle so the next
|
||||
* `getPaymentInstruments()` binds against the global `MeterProvider`
|
||||
* that is in effect at that point. Tests call this after installing a
|
||||
* fresh `MeterProvider`.
|
||||
*/
|
||||
export function resetPaymentInstruments(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createPaymentPlugin } from "./plugin.js";
|
||||
@@ -0,0 +1,70 @@
|
||||
# plugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Factory + descriptor for the `@suaveplan/api-plugin-payment` Hono plugin. Wraps a caller-injected `PaymentProvider` (Stripe, Adyen, PayPal, Square, Mollie, or a custom impl) behind 7 owner-scoped Zod-typed OpenAPI routes, an HMAC-verified webhook receiver, and a typed `paymentService` slot in the host's `ServiceRegistry`. The descriptor implements the canonical 6-step `onInit` lifecycle every `@suaveplan/api-plugin-*` package follows.
|
||||
|
||||
This is the entry point downstream consumers invoke from their `defineApp().use(...)` chain — `createPaymentPlugin({ provider, webhookSecret })` returns a `PluginDescriptor` that wires every moving part.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod-validated options at the factory boundary; invalid input throws `PaymentPluginError(INVALID_INPUT)` with the `ZodError` chained as `cause`.
|
||||
- 6-step `onInit`: resolve `app`/`openAPIRegistry` → assert provider → construct service → register service slot → register routes → log readiness.
|
||||
- `SERVICE_NOT_AVAILABLE` when `app` is absent.
|
||||
- `ROUTE_PREFIX_COLLISION` when the configured prefix overlaps with another plugin's routes.
|
||||
- Mounts the idempotency middleware on `POST /intents` and `POST /intents/:id/refund` only (read routes stay un-cached).
|
||||
- Mounts the webhook receiver as a separate sub-router that reads the raw body before deserialisation.
|
||||
- Falls back to mounting routes on the Hono app directly when `openAPIRegistry` is absent (the OpenAPI doc loses the routes; runtime keeps working).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { defineApp } from "@suaveplan/api";
|
||||
import { createAuthPlugin } from "@suaveplan/api-plugin-auth";
|
||||
import { createPaymentPlugin } from "@suaveplan/api-plugin-payment";
|
||||
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
|
||||
|
||||
const app = defineApp()
|
||||
.use(createAuthPlugin({ betterAuth: { /* ... */ } }))
|
||||
.use(createPaymentPlugin({
|
||||
provider: new StripeProvider({ apiKey: process.env.STRIPE_KEY! }),
|
||||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
|
||||
}));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createPaymentPlugin } from "@suaveplan/api-plugin-payment";
|
||||
import { MemoryPaymentIntentStore } from "@suaveplan/api-plugin-payment/store/memory-store";
|
||||
|
||||
const plugin = createPaymentPlugin({
|
||||
provider: customProvider,
|
||||
webhookSecret,
|
||||
routePrefix: "/billing",
|
||||
store: new MemoryPaymentIntentStore(),
|
||||
webhookToleranceMs: 60_000,
|
||||
webhookAlgorithm: "SHA-512",
|
||||
clock: { now: () => testClock.nowMs() },
|
||||
logger: appLogger,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createPaymentPlugin(options: PaymentPluginOptions): PluginDescriptor<PaymentPluginConfig>`.
|
||||
- Required options: `provider: PaymentProvider`, `webhookSecret: string` (≥8 chars).
|
||||
- Optional: `routePrefix`, `store`, `webhookToleranceMs`, `webhookAlgorithm`, `clock`, `logger`.
|
||||
- Throws `PaymentPluginError(INVALID_INPUT)` on Zod failure.
|
||||
- Throws `PaymentPluginError(SERVICE_NOT_AVAILABLE)` when `app` is missing at `onInit`.
|
||||
- Throws `PaymentPluginError(ROUTE_PREFIX_COLLISION)` when the prefix overlaps existing routes.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory takes the resolved Zod-output and binds it as the descriptor's `defaults`, then passes the same value into `onInit` so both the registry and the host see identical configuration. The `defaults` field is the only way the registry surfaces the resolved config back to the plugin's lifecycle hooks (`ctx.getOwnConfig()` reads it).
|
||||
|
||||
The webhook receiver is mounted as a separate `Hono` sub-router because it must NOT be routed through `OpenAPIRegistry` — the verification step reads the raw body and the OpenAPI 3.1 doc would still be served if the call returned 400 for an invalid signature. Keeping the receiver out of the registry means the auto-generated client SDKs don't expose `POST /webhook` as a "user-callable" endpoint, which is the correct semantic.
|
||||
@@ -0,0 +1,441 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FakePaymentProvider } from "../../tests/setup/fake-provider.js";
|
||||
import {
|
||||
PAYMENT_PLUGIN_CODES,
|
||||
type PaymentPluginError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { resetPaymentInstruments } from "../otel/instruments.js";
|
||||
import type { PaymentPluginConfig } from "../types/types.js";
|
||||
import { createPaymentPlugin } from "./plugin.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
function makeProvider() {
|
||||
return new FakePaymentProvider();
|
||||
}
|
||||
|
||||
function makeOpts() {
|
||||
return {
|
||||
provider: makeProvider(),
|
||||
webhookSecret: "shared-webhook-secret-32-chars-or-more",
|
||||
};
|
||||
}
|
||||
|
||||
function makeContext(
|
||||
plugin: ReturnType<typeof createPaymentPlugin>,
|
||||
args: {
|
||||
services: Map<string, unknown>;
|
||||
}
|
||||
) {
|
||||
return createPluginContext<PaymentPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as PaymentPluginConfig,
|
||||
services: args.services,
|
||||
});
|
||||
}
|
||||
|
||||
describe("createPaymentPlugin", () => {
|
||||
it("returns a valid PluginDescriptor", { timeout: 5000 }, () => {
|
||||
const plugin = createPaymentPlugin(makeOpts());
|
||||
expect(plugin.name).toBe("payment");
|
||||
expect(typeof plugin.onInit).toBe("function");
|
||||
});
|
||||
|
||||
it("invalid options raise PAYMENT_PLUGIN.INVALID_INPUT", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
createPaymentPlugin({} as never);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as PaymentPluginError).pluginCode).toBe(
|
||||
PAYMENT_PLUGIN_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPaymentPlugin — onInit lifecycle", () => {
|
||||
it("missing app raises SERVICE_NOT_AVAILABLE", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
resetPaymentInstruments();
|
||||
const plugin = createPaymentPlugin(makeOpts());
|
||||
try {
|
||||
await plugin.onInit?.(makeContext(plugin, { services: new Map() }));
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as PaymentPluginError).pluginCode).toBe(
|
||||
PAYMENT_PLUGIN_CODES.SERVICE_NOT_AVAILABLE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("registers the paymentService slot and routes when app is present", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
resetPaymentInstruments();
|
||||
const plugin = createPaymentPlugin(makeOpts());
|
||||
const services = new Map<string, unknown>();
|
||||
const app = new OpenAPIHono();
|
||||
services.set("app", app);
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.1" },
|
||||
});
|
||||
services.set("openAPIRegistry", registry);
|
||||
await plugin.onInit?.(makeContext(plugin, { services }));
|
||||
expect(services.has("paymentService")).toBe(true);
|
||||
const paths = registry.getRoutes().map((r) => r.path);
|
||||
expect(paths).toContain("/payments/intents");
|
||||
expect(paths).toContain("/payments/intents/{id}/refund");
|
||||
});
|
||||
|
||||
it("mounts routes without openAPIRegistry (warns)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
resetPaymentInstruments();
|
||||
const plugin = createPaymentPlugin(makeOpts());
|
||||
const services = new Map<string, unknown>();
|
||||
const app = new OpenAPIHono();
|
||||
services.set("app", app);
|
||||
await plugin.onInit?.(makeContext(plugin, { services }));
|
||||
expect(services.has("paymentService")).toBe(true);
|
||||
});
|
||||
|
||||
it("routePrefix collision raises ROUTE_PREFIX_COLLISION", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
resetPaymentInstruments();
|
||||
const plugin = createPaymentPlugin(makeOpts());
|
||||
const services = new Map<string, unknown>();
|
||||
const app = new OpenAPIHono();
|
||||
services.set("app", app);
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.1" },
|
||||
});
|
||||
registry.registerRoute({
|
||||
method: "get",
|
||||
path: "/payments/intents",
|
||||
tags: [],
|
||||
pluginName: "other",
|
||||
});
|
||||
services.set("openAPIRegistry", registry);
|
||||
try {
|
||||
await plugin.onInit?.(makeContext(plugin, { services }));
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as PaymentPluginError).pluginCode).toBe(
|
||||
PAYMENT_PLUGIN_CODES.ROUTE_PREFIX_COLLISION
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("honours a custom routePrefix", { timeout: 5000 }, async () => {
|
||||
resetPaymentInstruments();
|
||||
const plugin = createPaymentPlugin({
|
||||
...makeOpts(),
|
||||
routePrefix: "/billing",
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
const app = new OpenAPIHono();
|
||||
services.set("app", app);
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.1" },
|
||||
});
|
||||
services.set("openAPIRegistry", registry);
|
||||
await plugin.onInit?.(makeContext(plugin, { services }));
|
||||
const paths = registry.getRoutes().map((r) => r.path);
|
||||
expect(paths).toContain("/billing/intents");
|
||||
});
|
||||
|
||||
it("mounts on Hono sub-router when openAPIRegistry is absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
resetPaymentInstruments();
|
||||
const plugin = createPaymentPlugin(makeOpts());
|
||||
const services = new Map<string, unknown>();
|
||||
const app = new OpenAPIHono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u1", emailVerified: true, roles: [] });
|
||||
await next();
|
||||
});
|
||||
services.set("app", app);
|
||||
await plugin.onInit?.(makeContext(plugin, { services }));
|
||||
const res = await app.request("/payments/intents", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
amount: { amount: "100", currency: "USD" },
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it("webhook endpoint accepts a signed payload + rejects bad signatures", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
resetPaymentInstruments();
|
||||
const opts = makeOpts();
|
||||
const plugin = createPaymentPlugin(opts);
|
||||
const services = new Map<string, unknown>();
|
||||
const app = new OpenAPIHono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", null);
|
||||
await next();
|
||||
});
|
||||
services.set("app", app);
|
||||
services.set(
|
||||
"openAPIRegistry",
|
||||
createOpenAPIRegistry({ info: { title: "t", version: "0.1" } })
|
||||
);
|
||||
await plugin.onInit?.(makeContext(plugin, { services }));
|
||||
const { signPaymentWebhook } = await import(
|
||||
"../webhook/verify-webhook.js"
|
||||
);
|
||||
const body = JSON.stringify({
|
||||
id: "evt_1",
|
||||
type: "intent.captured",
|
||||
timestamp: 1,
|
||||
data: {},
|
||||
});
|
||||
const ts = Date.now();
|
||||
const sig = signPaymentWebhook({
|
||||
body,
|
||||
timestamp: ts,
|
||||
secret: opts.webhookSecret,
|
||||
algorithm: "SHA-256",
|
||||
});
|
||||
const okRes = await app.request("/payments/webhook", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"X-Signature": sig,
|
||||
"X-Timestamp": String(ts),
|
||||
},
|
||||
body,
|
||||
});
|
||||
expect(okRes.status).toBe(200);
|
||||
const badRes = await app.request("/payments/webhook", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"X-Signature": "deadbeef",
|
||||
"X-Timestamp": String(ts),
|
||||
},
|
||||
body,
|
||||
});
|
||||
expect(badRes.status).toBe(400);
|
||||
});
|
||||
|
||||
it("onError handler returns plain text for non-PaymentPluginError throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
resetPaymentInstruments();
|
||||
const plugin = createPaymentPlugin(makeOpts());
|
||||
const services = new Map<string, unknown>();
|
||||
const app = new OpenAPIHono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u1", emailVerified: true, roles: [] });
|
||||
await next();
|
||||
});
|
||||
services.set("app", app);
|
||||
services.set(
|
||||
"openAPIRegistry",
|
||||
createOpenAPIRegistry({ info: { title: "t", version: "0.1" } })
|
||||
);
|
||||
await plugin.onInit?.(makeContext(plugin, { services }));
|
||||
app.get("/payments/_explode", () => {
|
||||
throw new TypeError("explode");
|
||||
});
|
||||
const res = await app.request("/payments/_explode");
|
||||
expect(res.status).toBe(500);
|
||||
const text = await res.text();
|
||||
expect(text).toContain("Internal Error");
|
||||
});
|
||||
|
||||
it("onError handler falls back to 500 when PaymentPluginError has no statusCode", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
resetPaymentInstruments();
|
||||
const plugin = createPaymentPlugin(makeOpts());
|
||||
const services = new Map<string, unknown>();
|
||||
const app = new OpenAPIHono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u1", emailVerified: true, roles: [] });
|
||||
await next();
|
||||
});
|
||||
services.set("app", app);
|
||||
services.set(
|
||||
"openAPIRegistry",
|
||||
createOpenAPIRegistry({ info: { title: "t", version: "0.1" } })
|
||||
);
|
||||
await plugin.onInit?.(makeContext(plugin, { services }));
|
||||
const { PaymentPluginError, PAYMENT_PLUGIN_CODES } = await import(
|
||||
"../error-codes/error-codes.js"
|
||||
);
|
||||
app.get("/payments/_no-status", () => {
|
||||
throw new PaymentPluginError(
|
||||
PAYMENT_PLUGIN_CODES.PROVIDER_ERROR,
|
||||
"no status code"
|
||||
);
|
||||
});
|
||||
const res = await app.request("/payments/_no-status");
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("idempotency wrapper passes GET requests straight through", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
resetPaymentInstruments();
|
||||
const plugin = createPaymentPlugin(makeOpts());
|
||||
const services = new Map<string, unknown>();
|
||||
const app = new OpenAPIHono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u1", emailVerified: true, roles: [] });
|
||||
await next();
|
||||
});
|
||||
services.set("app", app);
|
||||
services.set(
|
||||
"openAPIRegistry",
|
||||
createOpenAPIRegistry({ info: { title: "t", version: "0.1" } })
|
||||
);
|
||||
await plugin.onInit?.(makeContext(plugin, { services }));
|
||||
const res = await app.request("/payments/intents", { method: "GET" });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("passes config.clock through to the service", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
resetPaymentInstruments();
|
||||
const plugin = createPaymentPlugin({
|
||||
...makeOpts(),
|
||||
clock: () => 42,
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
const app = new OpenAPIHono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u1", emailVerified: true, roles: [] });
|
||||
await next();
|
||||
});
|
||||
services.set("app", app);
|
||||
services.set(
|
||||
"openAPIRegistry",
|
||||
createOpenAPIRegistry({ info: { title: "t", version: "0.1" } })
|
||||
);
|
||||
await plugin.onInit?.(makeContext(plugin, { services }));
|
||||
const res = await app.request("/payments/intents", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
amount: { amount: "100", currency: "USD" },
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it("refund-path idempotency wrapper passes GET requests straight through", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
resetPaymentInstruments();
|
||||
const plugin = createPaymentPlugin(makeOpts());
|
||||
const services = new Map<string, unknown>();
|
||||
const app = new OpenAPIHono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u1", emailVerified: true, roles: [] });
|
||||
await next();
|
||||
});
|
||||
services.set("app", app);
|
||||
services.set(
|
||||
"openAPIRegistry",
|
||||
createOpenAPIRegistry({ info: { title: "t", version: "0.1" } })
|
||||
);
|
||||
await plugin.onInit?.(makeContext(plugin, { services }));
|
||||
// GET to /payments/intents/:id/refund — no such route, but
|
||||
// exercises the refundPath middleware's GET pass-through branch.
|
||||
const res = await app.request("/payments/intents/pi_x/refund", {
|
||||
method: "GET",
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("mounts on Hono sub-router at root '/' when openAPIRegistry is absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
resetPaymentInstruments();
|
||||
const plugin = createPaymentPlugin({
|
||||
...makeOpts(),
|
||||
routePrefix: "/",
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
const app = new OpenAPIHono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u1", emailVerified: true, roles: [] });
|
||||
await next();
|
||||
});
|
||||
services.set("app", app);
|
||||
await plugin.onInit?.(makeContext(plugin, { services }));
|
||||
const res = await app.request("/intents", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
amount: { amount: "100", currency: "USD" },
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it("webhook receiver coerces missing headers to null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
resetPaymentInstruments();
|
||||
const plugin = createPaymentPlugin(makeOpts());
|
||||
const services = new Map<string, unknown>();
|
||||
const app = new OpenAPIHono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", null);
|
||||
await next();
|
||||
});
|
||||
services.set("app", app);
|
||||
services.set(
|
||||
"openAPIRegistry",
|
||||
createOpenAPIRegistry({ info: { title: "t", version: "0.1" } })
|
||||
);
|
||||
await plugin.onInit?.(makeContext(plugin, { services }));
|
||||
const res = await app.request("/payments/webhook", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("honours a root '/' routePrefix", { timeout: 5000 }, async () => {
|
||||
resetPaymentInstruments();
|
||||
const plugin = createPaymentPlugin({
|
||||
...makeOpts(),
|
||||
routePrefix: "/",
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
const app = new OpenAPIHono();
|
||||
services.set("app", app);
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.1" },
|
||||
});
|
||||
services.set("openAPIRegistry", registry);
|
||||
await plugin.onInit?.(makeContext(plugin, { services }));
|
||||
const paths = registry.getRoutes().map((r) => r.path);
|
||||
expect(paths).toContain("/intents");
|
||||
});
|
||||
});
|
||||
247
packages/api-stack/api/api-plugin-payment/src/plugin/plugin.ts
Normal file
247
packages/api-stack/api/api-plugin-payment/src/plugin/plugin.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import type { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import {
|
||||
definePluginRoutes,
|
||||
type OpenAPIRegistry,
|
||||
} from "@suaveplan/api-openapi";
|
||||
import {
|
||||
definePlugin,
|
||||
type PluginContext,
|
||||
type PluginDescriptor,
|
||||
} from "@suaveplan/api-plugin";
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
PAYMENT_PLUGIN_CODES,
|
||||
PaymentPluginError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { createPaymentIdempotencyMiddleware } from "../middleware/idempotency-middleware.js";
|
||||
import { getPaymentInstruments } from "../otel/instruments.js";
|
||||
import { buildPaymentRoutes } from "../routes/build-routes.js";
|
||||
import { PaymentPluginOptionsSchema } from "../schemas/options.js";
|
||||
import { WebhookResponseSchema } from "../schemas/routes/webhook.js";
|
||||
import { createPaymentService } from "../services/payment-service.js";
|
||||
import "../services/registry-augmentation.js";
|
||||
import { MemoryPaymentIntentStore } from "../store/memory-store.js";
|
||||
import type {
|
||||
PaymentPluginConfig,
|
||||
PaymentPluginOptions,
|
||||
} from "../types/types.js";
|
||||
import { verifyPaymentWebhook } from "../webhook/verify-webhook.js";
|
||||
|
||||
/**
|
||||
* Factory — produces a typed `PluginDescriptor` for the payment plugin.
|
||||
*
|
||||
* Zod-validates the supplied options at the boundary so invalid input
|
||||
* fails fast with `PaymentPluginError(INVALID_INPUT)` carrying the
|
||||
* `ZodError` as `cause`. The returned descriptor's `onInit` executes
|
||||
* the canonical 6-step lifecycle (see proposal `## What changes`).
|
||||
*/
|
||||
export function createPaymentPlugin(
|
||||
options: PaymentPluginOptions
|
||||
): PluginDescriptor<PaymentPluginConfig> {
|
||||
const parsed = PaymentPluginOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new PaymentPluginError(
|
||||
PAYMENT_PLUGIN_CODES.INVALID_INPUT,
|
||||
"createPaymentPlugin received invalid options",
|
||||
{ cause: parsed.error, context: { statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
const resolved = parsed.data as unknown as PaymentPluginConfig;
|
||||
|
||||
return definePlugin<PaymentPluginConfig>({
|
||||
name: "payment",
|
||||
version: "0.1.0",
|
||||
description:
|
||||
"Payment-lifecycle plugin for @suaveplan/api — wraps @suaveplan/payment + a caller-injected PaymentProvider behind 6 owner-scoped Zod-typed OpenAPI routes plus an HMAC-verified webhook receiver.",
|
||||
defaults: resolved,
|
||||
tags: ["payment", "billing"],
|
||||
onInit: async (ctx) => runOnInit(ctx, resolved),
|
||||
});
|
||||
}
|
||||
|
||||
async function runOnInit(
|
||||
ctx: PluginContext<PaymentPluginConfig>,
|
||||
config: PaymentPluginConfig
|
||||
): Promise<void> {
|
||||
// (1) Resolve required services.
|
||||
const app = ctx.getService("app");
|
||||
if (app === undefined) {
|
||||
throw new PaymentPluginError(
|
||||
PAYMENT_PLUGIN_CODES.SERVICE_NOT_AVAILABLE,
|
||||
"payment plugin requires the `app` service in ServiceRegistry",
|
||||
{ context: { missing: ["app"], statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
const openAPIRegistry = ctx.getService("openAPIRegistry");
|
||||
const logger = config.logger ?? ctx.logger;
|
||||
|
||||
// (2) Resolve caller-injected provider (no default).
|
||||
const provider = config.provider;
|
||||
|
||||
// (3) Construct the PaymentService wrapping provider + store.
|
||||
const store = config.store ?? new MemoryPaymentIntentStore();
|
||||
const service = createPaymentService({
|
||||
provider,
|
||||
store,
|
||||
...(config.clock !== undefined ? { clock: config.clock } : {}),
|
||||
});
|
||||
|
||||
// (4) Register paymentService in ServiceRegistry.
|
||||
ctx.registerService("paymentService", service);
|
||||
|
||||
// (5) Register the 7 owner-scoped OpenAPI routes + the webhook receiver.
|
||||
registerRoutes({
|
||||
app,
|
||||
openAPIRegistry,
|
||||
config,
|
||||
service,
|
||||
});
|
||||
|
||||
// (6) Log readiness.
|
||||
logger.info("payment plugin initialised", {
|
||||
provider: provider.name,
|
||||
routePrefix: config.routePrefix,
|
||||
});
|
||||
}
|
||||
|
||||
function registerRoutes(args: {
|
||||
app: OpenAPIHono;
|
||||
openAPIRegistry: OpenAPIRegistry | undefined;
|
||||
config: PaymentPluginConfig;
|
||||
service: ReturnType<typeof createPaymentService>;
|
||||
}): void {
|
||||
const { app, openAPIRegistry, config, service } = args;
|
||||
const { routePrefix } = config;
|
||||
const idempotencyMiddleware = createPaymentIdempotencyMiddleware();
|
||||
|
||||
// Install an error translator so PaymentPluginError thrown by any
|
||||
// middleware (notably the idempotency-conflict path) becomes the
|
||||
// configured HTTP status instead of Hono's default 500.
|
||||
app.onError((err, c) => {
|
||||
if (err instanceof PaymentPluginError) {
|
||||
const ctx = err.context as { statusCode?: number } | undefined;
|
||||
const status = ctx?.statusCode ?? 500;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: { code: err.code, message: err.message },
|
||||
}),
|
||||
{
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
return c.text(`Internal Error: ${err.message}`, 500);
|
||||
});
|
||||
|
||||
// Attach the idempotency middleware BEFORE route registration so
|
||||
// Hono's middleware chain fires it before the route handler.
|
||||
const createPath = joinPath(routePrefix, "/intents");
|
||||
const refundPath = joinPath(routePrefix, "/intents/:id/refund");
|
||||
app.use(createPath, async (c, next) => {
|
||||
if (c.req.method !== "POST") return next();
|
||||
return idempotencyMiddleware(c, next);
|
||||
});
|
||||
app.use(refundPath, async (c, next) => {
|
||||
if (c.req.method !== "POST") return next();
|
||||
return idempotencyMiddleware(c, next);
|
||||
});
|
||||
|
||||
const entries = buildPaymentRoutes({ service, idempotencyMiddleware });
|
||||
|
||||
if (openAPIRegistry !== undefined) {
|
||||
const existing = openAPIRegistry
|
||||
.getRoutes()
|
||||
.filter((r) => r.path.startsWith(routePrefix));
|
||||
if (existing.length > 0) {
|
||||
throw new PaymentPluginError(
|
||||
PAYMENT_PLUGIN_CODES.ROUTE_PREFIX_COLLISION,
|
||||
`Route prefix '${routePrefix}' collides with previously registered routes`,
|
||||
{
|
||||
context: {
|
||||
prefix: routePrefix,
|
||||
statusCode: 500,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
const prefixedEntries = entries.map((entry) => ({
|
||||
...entry,
|
||||
route: {
|
||||
...entry.route,
|
||||
path: `${routePrefix === "/" ? "" : routePrefix}${entry.route.path}`,
|
||||
},
|
||||
}));
|
||||
definePluginRoutes(prefixedEntries, {
|
||||
app,
|
||||
registry: openAPIRegistry,
|
||||
pluginName: "payment",
|
||||
});
|
||||
} else {
|
||||
const sub = new Hono();
|
||||
for (const entry of entries) {
|
||||
const method = entry.route.method as
|
||||
| "get"
|
||||
| "post"
|
||||
| "put"
|
||||
| "patch"
|
||||
| "delete";
|
||||
sub[method](
|
||||
entry.route.path,
|
||||
async (c) => (await entry.handler(c as never)) as Response
|
||||
);
|
||||
}
|
||||
app.route(routePrefix === "/" ? "/" : routePrefix, sub);
|
||||
}
|
||||
|
||||
// Mount the webhook receiver as a separate sub-router that reads
|
||||
// the raw body BEFORE deserialisation.
|
||||
const webhookSub = new Hono();
|
||||
webhookSub.post("/webhook", async (c) => {
|
||||
const instruments = getPaymentInstruments();
|
||||
const body = await c.req.text();
|
||||
try {
|
||||
verifyPaymentWebhook({
|
||||
body,
|
||||
signature: c.req.header("X-Signature") ?? null,
|
||||
timestamp: c.req.header("X-Timestamp") ?? null,
|
||||
secret: config.webhookSecret,
|
||||
toleranceMs: config.webhookToleranceMs,
|
||||
algorithm: config.webhookAlgorithm,
|
||||
});
|
||||
} catch (err) {
|
||||
instruments.webhookSignatureFailuresTotal.add(1, {
|
||||
provider: service.providerName,
|
||||
});
|
||||
instruments.webhookReceivedTotal.add(1, {
|
||||
outcome: "rejected",
|
||||
provider: service.providerName,
|
||||
});
|
||||
// `verifyPaymentWebhook` only throws PaymentPluginError —
|
||||
// every internal failure path is wrapped. Cast is safe.
|
||||
const pluginErr = err as PaymentPluginError;
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: pluginErr.code,
|
||||
message: pluginErr.message,
|
||||
},
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
instruments.webhookReceivedTotal.add(1, {
|
||||
outcome: "accepted",
|
||||
provider: service.providerName,
|
||||
});
|
||||
return c.json(WebhookResponseSchema.parse({ accepted: true }), 200);
|
||||
});
|
||||
app.route(routePrefix === "/" ? "/" : routePrefix, webhookSub);
|
||||
}
|
||||
|
||||
function joinPath(prefix: string, suffix: string): string {
|
||||
if (prefix === "/") return suffix;
|
||||
return `${prefix}${suffix}`;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
# build-routes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Builds the 7 OpenAPI route entries the plugin mounts for the payment lifecycle (create / list / get / confirm / capture / cancel / refund). Each entry pairs a `RouteConfig` built with `RouteBuilder` (so Zod inference flows end-to-end into the OpenAPI 3.1 doc) with a handler that delegates to `PaymentService` and gates the call behind `requireAuth()` from `@suaveplan/api-plugin-auth`.
|
||||
|
||||
The handlers extract the authenticated `Principal` from `c.get("user")`, derive an `ownerId` (+ optional `tenantId`), and pass it to every service call. The service is responsible for the ownership check and 404 anti-enumeration — the route handler stays thin so the policy lives in one place.
|
||||
|
||||
## Features
|
||||
|
||||
- 7 routes per the proposal: create, list, get, confirm, capture, cancel, refund.
|
||||
- Every route is `requireAuth()`-gated via an internal middleware chain helper.
|
||||
- Per-route Zod schemas wire end-to-end into the OpenAPI doc.
|
||||
- Capture handler emits the `payments.captures_total{outcome:"success"}` counter on success — the service handles the failure case so both branches are observed.
|
||||
- Handlers tolerate empty bodies on capture/refund and parse with safe defaults.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildPaymentRoutes } from "@suaveplan/api-plugin-payment/routes/build-routes";
|
||||
|
||||
const entries = buildPaymentRoutes({ service });
|
||||
definePluginRoutes(entries, { app, registry, pluginName: "payment" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { buildPaymentRoutes } from "@suaveplan/api-plugin-payment/routes/build-routes";
|
||||
import { createPaymentIdempotencyMiddleware } from "@suaveplan/api-plugin-payment/middleware/idempotency-middleware";
|
||||
|
||||
const idemMW = createPaymentIdempotencyMiddleware();
|
||||
const entries = buildPaymentRoutes({
|
||||
service,
|
||||
idempotencyMiddleware: idemMW,
|
||||
});
|
||||
// idempotency is wired separately by the plugin onto POST /intents and POST .../refund
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildPaymentRoutes({ service, idempotencyMiddleware? }): PluginRouteEntry[]`.
|
||||
- `BuildPaymentRoutesOptions` — `{ service: PaymentService, idempotencyMiddleware?: MiddlewareHandler }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The internal `chain([auth], handler)` wrapper composes middleware + handler into the single function `PluginRouteEntry.handler` expects, so the routes can share `requireAuth()` without forcing the host to attach it on every path. The idempotency middleware lives outside `chain` because the plugin attaches it at mount time only to the two routes that need it (`POST /intents` and `POST /intents/:id/refund`) — wrapping every route in idempotency would silently change `GET` semantics.
|
||||
|
||||
The capture handler attempts `c.req.json()` then falls back to `{}` because partial-capture routes commonly receive an empty body and Hono raises on `await c.req.json()` against an empty payload. The `.catch(() => ({}))` keeps the happy path simple while still rejecting genuinely-malformed JSON via the subsequent `CaptureBodySchema.parse`.
|
||||
@@ -0,0 +1,360 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FakePaymentProvider } from "../../tests/setup/fake-provider.js";
|
||||
import { resetPaymentInstruments } from "../otel/instruments.js";
|
||||
import { createPaymentService } from "../services/payment-service.js";
|
||||
import { MemoryPaymentIntentStore } from "../store/memory-store.js";
|
||||
import { buildPaymentRoutes } from "./build-routes.js";
|
||||
|
||||
function makeEntries() {
|
||||
resetPaymentInstruments();
|
||||
const provider = new FakePaymentProvider();
|
||||
const store = new MemoryPaymentIntentStore();
|
||||
const service = createPaymentService({ provider, store });
|
||||
return { entries: buildPaymentRoutes({ service }), service, store };
|
||||
}
|
||||
|
||||
describe("buildPaymentRoutes", () => {
|
||||
it("returns exactly 7 route entries", { timeout: 5000 }, () => {
|
||||
const { entries } = makeEntries();
|
||||
expect(entries).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("every route is tagged 'payments'", { timeout: 5000 }, () => {
|
||||
const { entries } = makeEntries();
|
||||
for (const e of entries) {
|
||||
expect(e.route.tags).toContain("payments");
|
||||
}
|
||||
});
|
||||
|
||||
it("every route paths to an /intents subtree", { timeout: 5000 }, () => {
|
||||
const { entries } = makeEntries();
|
||||
for (const e of entries) {
|
||||
expect(e.route.path.startsWith("/intents")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("every route has a handler function", { timeout: 5000 }, () => {
|
||||
const { entries } = makeEntries();
|
||||
for (const e of entries) {
|
||||
expect(typeof e.handler).toBe("function");
|
||||
}
|
||||
});
|
||||
|
||||
it("includes create, list, get, confirm, capture, cancel, refund", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { entries } = makeEntries();
|
||||
const paths = entries.map(
|
||||
(e) => `${e.route.method.toUpperCase()} ${e.route.path}`
|
||||
);
|
||||
expect(paths).toContain("POST /intents");
|
||||
expect(paths).toContain("GET /intents");
|
||||
expect(paths).toContain("GET /intents/{id}");
|
||||
expect(paths).toContain("POST /intents/{id}/confirm");
|
||||
expect(paths).toContain("POST /intents/{id}/capture");
|
||||
expect(paths).toContain("POST /intents/{id}/cancel");
|
||||
expect(paths).toContain("POST /intents/{id}/refund");
|
||||
});
|
||||
|
||||
it("create handler forwards description + metadata to the service", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { entries, store } = makeEntries();
|
||||
const createEntry = entries.find(
|
||||
(e) => e.route.method === "post" && e.route.path === "/intents"
|
||||
);
|
||||
if (!createEntry) throw new Error("missing create entry");
|
||||
const c = {
|
||||
get: () => ({
|
||||
id: "u1",
|
||||
emailVerified: true,
|
||||
roles: [],
|
||||
tenantId: "t1",
|
||||
}),
|
||||
req: {
|
||||
param: () => ({}),
|
||||
query: () => ({}),
|
||||
json: async () => ({
|
||||
amount: { amount: "100", currency: "USD" },
|
||||
description: "Order #42",
|
||||
metadata: { order: "42" },
|
||||
}),
|
||||
raw: new Request("http://x/intents", { method: "POST" }),
|
||||
},
|
||||
json: (body: unknown, status: number) =>
|
||||
new Response(JSON.stringify(body), { status }),
|
||||
} as never;
|
||||
const res = (await createEntry.handler(c)) as Response;
|
||||
expect(res.status).toBe(201);
|
||||
const stored = await store.listByOwner("u1");
|
||||
expect(stored[0]?.tenantId).toBe("t1");
|
||||
});
|
||||
|
||||
it("capture handler forwards an explicit amount", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { entries, service } = makeEntries();
|
||||
const created = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 500n, currency: "USD" } },
|
||||
});
|
||||
const capEntry = entries.find(
|
||||
(e) =>
|
||||
e.route.method === "post" &&
|
||||
e.route.path === "/intents/{id}/capture"
|
||||
);
|
||||
if (!capEntry) throw new Error("missing capture entry");
|
||||
const c = {
|
||||
get: () => ({ id: "u1", emailVerified: true, roles: [] }),
|
||||
req: {
|
||||
param: () => ({ id: created.id }),
|
||||
query: () => ({}),
|
||||
json: async () => ({
|
||||
amount: { amount: "300", currency: "USD" },
|
||||
}),
|
||||
raw: new Request("http://x", { method: "POST" }),
|
||||
},
|
||||
json: (body: unknown, status: number) =>
|
||||
new Response(JSON.stringify(body), { status }),
|
||||
} as never;
|
||||
const res = (await capEntry.handler(c)) as Response;
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("refund handler forwards full body (amount, reason, metadata)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { entries, service } = makeEntries();
|
||||
const created = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 1000n, currency: "USD" } },
|
||||
});
|
||||
await service.captureIntent({ id: created.id, ownerId: "u1" });
|
||||
const refundEntry = entries.find(
|
||||
(e) =>
|
||||
e.route.method === "post" &&
|
||||
e.route.path === "/intents/{id}/refund"
|
||||
);
|
||||
if (!refundEntry) throw new Error("missing refund entry");
|
||||
const c = {
|
||||
get: () => ({ id: "u1", emailVerified: true, roles: [] }),
|
||||
req: {
|
||||
param: () => ({ id: created.id }),
|
||||
query: () => ({}),
|
||||
json: async () => ({
|
||||
amount: { amount: "200", currency: "USD" },
|
||||
reason: "fraudulent",
|
||||
metadata: { ticket: "T-99" },
|
||||
}),
|
||||
raw: new Request("http://x", { method: "POST" }),
|
||||
},
|
||||
json: (body: unknown, status: number) =>
|
||||
new Response(JSON.stringify(body), { status }),
|
||||
} as never;
|
||||
const res = (await refundEntry.handler(c)) as Response;
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("chain returns 500 when PaymentPluginError has no statusCode", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { entries, service } = makeEntries();
|
||||
const created = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 1n, currency: "USD" } },
|
||||
});
|
||||
const getEntry = entries.find(
|
||||
(e) => e.route.method === "get" && e.route.path === "/intents/{id}"
|
||||
);
|
||||
if (!getEntry) throw new Error("missing get entry");
|
||||
const { PaymentPluginError, PAYMENT_PLUGIN_CODES } = await import(
|
||||
"../error-codes/error-codes.js"
|
||||
);
|
||||
const stubService = service as unknown as {
|
||||
getIntent: (a: unknown) => Promise<unknown>;
|
||||
};
|
||||
const original = stubService.getIntent.bind(stubService);
|
||||
stubService.getIntent = async () => {
|
||||
throw new PaymentPluginError(
|
||||
PAYMENT_PLUGIN_CODES.PROVIDER_ERROR,
|
||||
"boom"
|
||||
);
|
||||
};
|
||||
try {
|
||||
const c = {
|
||||
get: () => ({ id: "u1", emailVerified: true, roles: [] }),
|
||||
req: {
|
||||
param: () => ({ id: created.id }),
|
||||
query: () => ({}),
|
||||
json: async () => ({}),
|
||||
raw: new Request("http://x"),
|
||||
},
|
||||
json: (body: unknown, status: number) =>
|
||||
new Response(JSON.stringify(body), { status }),
|
||||
} as never;
|
||||
const res = (await getEntry.handler(c)) as Response;
|
||||
expect(res.status).toBe(500);
|
||||
} finally {
|
||||
stubService.getIntent = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("handler returns 404 when ownerScope finds no principal", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { entries } = makeEntries();
|
||||
// Pick a route that does NOT call requireAuth's throw path first
|
||||
// (we want ownerScope to be the layer that fires) — supply a
|
||||
// pre-set `user: { id: '...' }` to pass requireAuth, then null
|
||||
// it for ownerScope by overriding `get`.
|
||||
const getEntry = entries.find(
|
||||
(e) => e.route.method === "get" && e.route.path === "/intents/{id}"
|
||||
);
|
||||
if (!getEntry) throw new Error("missing get entry");
|
||||
let toggle = true;
|
||||
const c = {
|
||||
// First call: requireAuth sees a non-null principal.
|
||||
// Second call (inside ownerScope): returns null to hit the
|
||||
// defensive branch.
|
||||
get: () => {
|
||||
if (toggle) {
|
||||
toggle = false;
|
||||
return { id: "u1", emailVerified: true, roles: [] };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
req: {
|
||||
param: () => ({ id: "pi_x" }),
|
||||
query: () => ({}),
|
||||
json: async () => ({}),
|
||||
raw: new Request("http://x/intents/pi_x"),
|
||||
},
|
||||
json: (body: unknown, status: number) =>
|
||||
new Response(JSON.stringify(body), { status }),
|
||||
} as never;
|
||||
const res = (await getEntry.handler(c)) as Response;
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("handler propagates unknown errors from inner call", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { entries } = makeEntries();
|
||||
const entry = entries.find(
|
||||
(e) => e.route.method === "post" && e.route.path === "/intents"
|
||||
);
|
||||
if (!entry) throw new Error("missing post entry");
|
||||
const c = {
|
||||
get: () => ({ id: "u1", emailVerified: true, roles: [] }),
|
||||
req: {
|
||||
param: () => ({}),
|
||||
query: () => ({}),
|
||||
json: async () => {
|
||||
throw new TypeError("kaboom");
|
||||
},
|
||||
raw: new Request("http://x/intents", { method: "POST" }),
|
||||
},
|
||||
json: (body: unknown, status: number) =>
|
||||
new Response(JSON.stringify(body), { status }),
|
||||
} as never;
|
||||
let caught: unknown;
|
||||
try {
|
||||
await entry.handler(c);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(TypeError);
|
||||
});
|
||||
|
||||
it("list handler filters by status when query supplies one", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { entries, service } = makeEntries();
|
||||
await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 1n, currency: "USD" } },
|
||||
});
|
||||
const listEntry = entries.find(
|
||||
(e) => e.route.method === "get" && e.route.path === "/intents"
|
||||
);
|
||||
if (!listEntry) throw new Error("missing list entry");
|
||||
const c = {
|
||||
get: () => ({ id: "u1", emailVerified: true, roles: [] }),
|
||||
req: {
|
||||
param: () => ({}),
|
||||
query: () => ({ status: "captured" }),
|
||||
json: async () => ({}),
|
||||
raw: new Request("http://x/intents?status=captured"),
|
||||
},
|
||||
json: (body: unknown, status: number) =>
|
||||
new Response(JSON.stringify(body), { status }),
|
||||
} as never;
|
||||
const res = (await listEntry.handler(c)) as Response;
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { intents: unknown[] };
|
||||
expect(body.intents).toEqual([]);
|
||||
});
|
||||
|
||||
it("capture handler tolerates malformed JSON body", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { entries, service } = makeEntries();
|
||||
const created = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 1n, currency: "USD" } },
|
||||
});
|
||||
const capEntry = entries.find(
|
||||
(e) =>
|
||||
e.route.method === "post" &&
|
||||
e.route.path === "/intents/{id}/capture"
|
||||
);
|
||||
if (!capEntry) throw new Error("missing capture entry");
|
||||
const c = {
|
||||
get: () => ({ id: "u1", emailVerified: true, roles: [] }),
|
||||
req: {
|
||||
param: () => ({ id: created.id }),
|
||||
query: () => ({}),
|
||||
json: async () => {
|
||||
throw new SyntaxError("bad json");
|
||||
},
|
||||
raw: new Request("http://x", { method: "POST" }),
|
||||
},
|
||||
json: (body: unknown, status: number) =>
|
||||
new Response(JSON.stringify(body), { status }),
|
||||
} as never;
|
||||
const res = (await capEntry.handler(c)) as Response;
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("refund handler tolerates malformed JSON body", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { entries, service } = makeEntries();
|
||||
const created = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 1n, currency: "USD" } },
|
||||
});
|
||||
await service.captureIntent({ id: created.id, ownerId: "u1" });
|
||||
const refundEntry = entries.find(
|
||||
(e) =>
|
||||
e.route.method === "post" &&
|
||||
e.route.path === "/intents/{id}/refund"
|
||||
);
|
||||
if (!refundEntry) throw new Error("missing refund entry");
|
||||
const c = {
|
||||
get: () => ({ id: "u1", emailVerified: true, roles: [] }),
|
||||
req: {
|
||||
param: () => ({ id: created.id }),
|
||||
query: () => ({}),
|
||||
json: async () => {
|
||||
throw new SyntaxError("bad json");
|
||||
},
|
||||
raw: new Request("http://x", { method: "POST" }),
|
||||
},
|
||||
json: (body: unknown, status: number) =>
|
||||
new Response(JSON.stringify(body), { status }),
|
||||
} as never;
|
||||
const res = (await refundEntry.handler(c)) as Response;
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
import {
|
||||
GET,
|
||||
type PluginRouteEntry,
|
||||
POST,
|
||||
type RouteHandler,
|
||||
successResponse,
|
||||
} from "@suaveplan/api-openapi";
|
||||
import { requireAuth } from "@suaveplan/api-plugin-auth";
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import {
|
||||
PAYMENT_PLUGIN_CODES,
|
||||
PaymentPluginError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { getPaymentInstruments } from "../otel/instruments.js";
|
||||
import {
|
||||
CaptureBodySchema,
|
||||
CreatePaymentIntentBodySchema,
|
||||
EmptyBodySchema,
|
||||
ListPaymentIntentsQuerySchema,
|
||||
PaymentIntentIdParamSchema,
|
||||
PaymentIntentListResponseSchema,
|
||||
PaymentIntentResponseSchema,
|
||||
RefundBodySchema,
|
||||
RefundResponseSchema,
|
||||
} from "../schemas/routes/index.js";
|
||||
import type { PaymentService } from "../services/payment-service.js";
|
||||
import "../middleware/context-augmentation.js";
|
||||
|
||||
/** Inputs to {@link buildPaymentRoutes}. */
|
||||
export interface BuildPaymentRoutesOptions {
|
||||
readonly service: PaymentService;
|
||||
readonly idempotencyMiddleware?: MiddlewareHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the 6 owner-scoped OpenAPI route entries that
|
||||
* `createPaymentPlugin` mounts on the host Hono app. Each entry pairs
|
||||
* a `RouteConfig` (built with `RouteBuilder` so Zod inference flows
|
||||
* through to the OpenAPI doc) with a thin handler that delegates to
|
||||
* `PaymentService`.
|
||||
*
|
||||
* All routes are `requireAuth()`-gated via an outer middleware chain
|
||||
* the plugin attaches at mount time. Per-route metric emission is
|
||||
* done by the handler so the counter labels (`outcome`, `provider`)
|
||||
* reflect the actual provider call result.
|
||||
*/
|
||||
export function buildPaymentRoutes(
|
||||
options: BuildPaymentRoutesOptions
|
||||
): PluginRouteEntry[] {
|
||||
const { service } = options;
|
||||
|
||||
const createIntent = POST("/intents")
|
||||
.tags("payments")
|
||||
.summary("Create a new payment intent")
|
||||
.body(CreatePaymentIntentBodySchema)
|
||||
.response(successResponse(PaymentIntentResponseSchema))
|
||||
.build();
|
||||
|
||||
const listIntents = GET("/intents")
|
||||
.tags("payments")
|
||||
.summary("List intents owned by the authenticated user")
|
||||
.query(ListPaymentIntentsQuerySchema)
|
||||
.response(successResponse(PaymentIntentListResponseSchema))
|
||||
.build();
|
||||
|
||||
const getIntent = GET("/intents/{id}")
|
||||
.tags("payments")
|
||||
.summary("Fetch a single owned intent")
|
||||
.params(PaymentIntentIdParamSchema)
|
||||
.response(successResponse(PaymentIntentResponseSchema))
|
||||
.build();
|
||||
|
||||
const confirmIntent = POST("/intents/{id}/confirm")
|
||||
.tags("payments")
|
||||
.summary("Confirm an authorised intent")
|
||||
.params(PaymentIntentIdParamSchema)
|
||||
.body(EmptyBodySchema)
|
||||
.response(successResponse(PaymentIntentResponseSchema))
|
||||
.build();
|
||||
|
||||
const captureIntent = POST("/intents/{id}/capture")
|
||||
.tags("payments")
|
||||
.summary("Capture an authorised intent (optionally partial)")
|
||||
.params(PaymentIntentIdParamSchema)
|
||||
.body(CaptureBodySchema)
|
||||
.response(successResponse(PaymentIntentResponseSchema))
|
||||
.build();
|
||||
|
||||
const cancelIntent = POST("/intents/{id}/cancel")
|
||||
.tags("payments")
|
||||
.summary("Cancel an intent that has not been captured")
|
||||
.params(PaymentIntentIdParamSchema)
|
||||
.body(EmptyBodySchema)
|
||||
.response(successResponse(PaymentIntentResponseSchema))
|
||||
.build();
|
||||
|
||||
const refundIntent = POST("/intents/{id}/refund")
|
||||
.tags("payments")
|
||||
.summary("Refund a captured intent (optionally partial)")
|
||||
.params(PaymentIntentIdParamSchema)
|
||||
.body(RefundBodySchema)
|
||||
.response(successResponse(RefundResponseSchema))
|
||||
.build();
|
||||
|
||||
const auth = requireAuth();
|
||||
|
||||
return [
|
||||
{
|
||||
route: createIntent,
|
||||
handler: chain([auth], createIntentHandler(service)),
|
||||
},
|
||||
{
|
||||
route: listIntents,
|
||||
handler: chain([auth], listIntentsHandler(service)),
|
||||
},
|
||||
{
|
||||
route: getIntent,
|
||||
handler: chain([auth], getIntentHandler(service)),
|
||||
},
|
||||
{
|
||||
route: confirmIntent,
|
||||
handler: chain([auth], confirmIntentHandler(service)),
|
||||
},
|
||||
{
|
||||
route: captureIntent,
|
||||
handler: chain([auth], captureIntentHandler(service)),
|
||||
},
|
||||
{
|
||||
route: cancelIntent,
|
||||
handler: chain([auth], cancelIntentHandler(service)),
|
||||
},
|
||||
{
|
||||
route: refundIntent,
|
||||
handler: chain([auth], refundHandler(service)),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function serializeMoney(money: { amount: bigint; currency: string }): {
|
||||
amount: string;
|
||||
currency: string;
|
||||
} {
|
||||
return { amount: money.amount.toString(), currency: money.currency };
|
||||
}
|
||||
|
||||
function serializeRefund(
|
||||
refund: Awaited<ReturnType<PaymentService["refund"]>>
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...refund,
|
||||
amount: serializeMoney(refund.amount),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeIntent(
|
||||
intent: Awaited<ReturnType<PaymentService["getIntent"]>>
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...intent,
|
||||
amount: serializeMoney(intent.amount),
|
||||
capturedAmount: serializeMoney(intent.capturedAmount),
|
||||
refundedAmount: serializeMoney(intent.refundedAmount),
|
||||
refunds: intent.refunds.map(serializeRefund),
|
||||
};
|
||||
}
|
||||
|
||||
function ownerScope(c: Parameters<RouteHandler>[0]): {
|
||||
ownerId: string;
|
||||
tenantId?: string;
|
||||
} {
|
||||
const principal = c.get("user");
|
||||
if (principal === null || principal === undefined) {
|
||||
throw new PaymentPluginError(
|
||||
PAYMENT_PLUGIN_CODES.NOT_FOUND,
|
||||
"Authenticated principal required",
|
||||
{ context: { statusCode: 404 } }
|
||||
);
|
||||
}
|
||||
const scope: { ownerId: string; tenantId?: string } = {
|
||||
ownerId: principal.id,
|
||||
};
|
||||
if (principal.tenantId !== undefined) scope.tenantId = principal.tenantId;
|
||||
return scope;
|
||||
}
|
||||
|
||||
function createIntentHandler(service: PaymentService): RouteHandler {
|
||||
return async (c) => {
|
||||
const body = CreatePaymentIntentBodySchema.parse(await c.req.json());
|
||||
const scope = ownerScope(c);
|
||||
const input: Parameters<PaymentService["createIntent"]>[0]["input"] = {
|
||||
amount: body.amount,
|
||||
captureMethod: body.captureMethod,
|
||||
...(body.description !== undefined
|
||||
? { description: body.description }
|
||||
: {}),
|
||||
...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
|
||||
};
|
||||
const intent = await service.createIntent({
|
||||
...scope,
|
||||
input,
|
||||
});
|
||||
return c.json({ intent: serializeIntent(intent) }, 201);
|
||||
};
|
||||
}
|
||||
|
||||
function listIntentsHandler(service: PaymentService): RouteHandler {
|
||||
return async (c) => {
|
||||
const query = ListPaymentIntentsQuerySchema.parse(c.req.query());
|
||||
const scope = ownerScope(c);
|
||||
const intents = await service.listIntents({
|
||||
ownerId: scope.ownerId,
|
||||
limit: query.limit,
|
||||
});
|
||||
const filtered =
|
||||
query.status !== undefined
|
||||
? intents.filter((i) => i.status === query.status)
|
||||
: intents;
|
||||
return c.json({ intents: filtered.map(serializeIntent) }, 200);
|
||||
};
|
||||
}
|
||||
|
||||
function getIntentHandler(service: PaymentService): RouteHandler {
|
||||
return async (c) => {
|
||||
const { id } = PaymentIntentIdParamSchema.parse(c.req.param());
|
||||
const scope = ownerScope(c);
|
||||
const intent = await service.getIntent({ id, ownerId: scope.ownerId });
|
||||
return c.json({ intent: serializeIntent(intent) }, 200);
|
||||
};
|
||||
}
|
||||
|
||||
function confirmIntentHandler(service: PaymentService): RouteHandler {
|
||||
return async (c) => {
|
||||
const { id } = PaymentIntentIdParamSchema.parse(c.req.param());
|
||||
const scope = ownerScope(c);
|
||||
const intent = await service.confirmIntent({
|
||||
id,
|
||||
ownerId: scope.ownerId,
|
||||
});
|
||||
return c.json({ intent: serializeIntent(intent) }, 200);
|
||||
};
|
||||
}
|
||||
|
||||
function captureIntentHandler(service: PaymentService): RouteHandler {
|
||||
return async (c) => {
|
||||
const { id } = PaymentIntentIdParamSchema.parse(c.req.param());
|
||||
const body = CaptureBodySchema.parse(
|
||||
(await c.req.json().catch(() => ({}))) as object
|
||||
);
|
||||
const scope = ownerScope(c);
|
||||
const intent = await service.captureIntent({
|
||||
id,
|
||||
ownerId: scope.ownerId,
|
||||
...(body.amount !== undefined ? { amount: body.amount } : {}),
|
||||
});
|
||||
getPaymentInstruments().capturesTotal.add(1, {
|
||||
outcome: "success",
|
||||
provider: service.providerName,
|
||||
});
|
||||
return c.json({ intent: serializeIntent(intent) }, 200);
|
||||
};
|
||||
}
|
||||
|
||||
function cancelIntentHandler(service: PaymentService): RouteHandler {
|
||||
return async (c) => {
|
||||
const { id } = PaymentIntentIdParamSchema.parse(c.req.param());
|
||||
const scope = ownerScope(c);
|
||||
const intent = await service.cancelIntent({
|
||||
id,
|
||||
ownerId: scope.ownerId,
|
||||
});
|
||||
return c.json({ intent: serializeIntent(intent) }, 200);
|
||||
};
|
||||
}
|
||||
|
||||
function refundHandler(service: PaymentService): RouteHandler {
|
||||
return async (c) => {
|
||||
const { id } = PaymentIntentIdParamSchema.parse(c.req.param());
|
||||
const body = RefundBodySchema.parse(
|
||||
(await c.req.json().catch(() => ({}))) as object
|
||||
);
|
||||
const scope = ownerScope(c);
|
||||
const refund = await service.refund({
|
||||
...scope,
|
||||
input: {
|
||||
paymentIntentId: id,
|
||||
...(body.amount !== undefined ? { amount: body.amount } : {}),
|
||||
...(body.reason !== undefined ? { reason: body.reason } : {}),
|
||||
...(body.metadata !== undefined
|
||||
? { metadata: body.metadata }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
return c.json({ refund: serializeRefund(refund) }, 200);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose a list of Hono middleware with a final handler into a single
|
||||
* `RouteHandler`. Each middleware runs in declaration order; the
|
||||
* handler runs after every middleware calls `next()`.
|
||||
*/
|
||||
function chain(
|
||||
middlewares: readonly MiddlewareHandler[],
|
||||
handler: RouteHandler
|
||||
): RouteHandler {
|
||||
return async (c) => {
|
||||
let lastResult: unknown;
|
||||
const run = async (idx: number): Promise<void> => {
|
||||
const mw = middlewares[idx];
|
||||
if (mw !== undefined) {
|
||||
await mw(c, () => run(idx + 1));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
lastResult = await handler(c);
|
||||
} catch (err) {
|
||||
if (err instanceof PaymentPluginError) {
|
||||
const ctx = err.context as
|
||||
| { statusCode?: number }
|
||||
| undefined;
|
||||
const status = ctx?.statusCode ?? 500;
|
||||
lastResult = new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
await run(0);
|
||||
return lastResult as Response;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
type BuildPaymentRoutesOptions,
|
||||
buildPaymentRoutes,
|
||||
} from "./build-routes.js";
|
||||
@@ -0,0 +1,43 @@
|
||||
# money
|
||||
|
||||
## Purpose
|
||||
|
||||
Boundary Zod schema for `MoneyLike` — `{ amount: bigint, currency: string }`. Payment APIs work in minor units (cents, pence, satoshi, lamports) and must never round-trip through IEEE-754 floats because rounding losses on `0.10 + 0.20` are exactly the kind of defect that ends up in a compliance audit. JSON cannot natively encode `bigint`, so the schema accepts either a real `bigint` literal, a finite JSON-number, or a numeric string and coerces via `BigInt()`.
|
||||
|
||||
The currency code accepts 3-8 uppercase alphanumeric characters covering ISO 4217 (`USD`, `EUR`, `JPY`), crypto (`BTC`, `ETH`), stablecoins (`USDC`), and Solana micro-units (`LAMPORTS`).
|
||||
|
||||
## Features
|
||||
|
||||
- `z.preprocess`-backed amount coercion handles three input shapes (`bigint` / `number` / numeric `string`) and refuses everything else (objects, floats expressed as strings, `NaN`).
|
||||
- Currency regex `^[A-Z0-9]+$` rejects lowercase, spaces, and mixed alphanumeric junk before any downstream lookup.
|
||||
- `nonnegative()` on the bigint prevents negative-amount intents which providers universally reject.
|
||||
- Inferred `Money` type drops in everywhere `MoneyLike` does because the structure is identical.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { MoneySchema } from "@suaveplan/api-plugin-payment/schemas/money";
|
||||
|
||||
const parsed = MoneySchema.parse({ amount: 999n, currency: "USD" });
|
||||
// { amount: 999n, currency: "USD" }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const result = MoneySchema.safeParse(await c.req.json());
|
||||
if (!result.success) {
|
||||
return c.json({ error: "invalid_money", issues: result.error.issues }, 400);
|
||||
}
|
||||
const { amount, currency } = result.data;
|
||||
const intent = await provider.createIntent({ amount: { amount, currency } });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `MoneySchema` — `z.object({ amount: bigint (coerced), currency: string (3..8, [A-Z0-9]+) })`.
|
||||
- `Money` — `z.infer<typeof MoneySchema>` — `{ amount: bigint; currency: string }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`z.preprocess` is used rather than `z.coerce.bigint()` because the latter happily produces `BigInt("12.5")` → `RangeError` and the error message is opaque to API consumers. The custom preprocessor inspects the input shape, only forwards values that BigInt can safely convert, and lets the downstream `z.bigint()` issue produce a clean failure message. Floating-point inputs are intentionally rejected — a fractional amount in minor units is a programming error, not a roundable value.
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MoneySchema } from "./money.js";
|
||||
|
||||
describe("MoneySchema", () => {
|
||||
it("accepts a bigint amount", { timeout: 5000 }, () => {
|
||||
const parsed = MoneySchema.parse({ amount: 1234n, currency: "USD" });
|
||||
expect(parsed.amount).toBe(1234n);
|
||||
expect(parsed.currency).toBe("USD");
|
||||
});
|
||||
|
||||
it("coerces a JSON-number into a bigint", { timeout: 5000 }, () => {
|
||||
const parsed = MoneySchema.parse({ amount: 99, currency: "EUR" });
|
||||
expect(parsed.amount).toBe(99n);
|
||||
});
|
||||
|
||||
it("coerces a numeric string into a bigint", { timeout: 5000 }, () => {
|
||||
const parsed = MoneySchema.parse({
|
||||
amount: "1000000",
|
||||
currency: "USD",
|
||||
});
|
||||
expect(parsed.amount).toBe(1_000_000n);
|
||||
});
|
||||
|
||||
it("rejects negative amounts", { timeout: 5000 }, () => {
|
||||
const result = MoneySchema.safeParse({
|
||||
amount: -10n,
|
||||
currency: "USD",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-numeric strings", { timeout: 5000 }, () => {
|
||||
const result = MoneySchema.safeParse({
|
||||
amount: "abc",
|
||||
currency: "USD",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects floating-point strings", { timeout: 5000 }, () => {
|
||||
const result = MoneySchema.safeParse({
|
||||
amount: "12.5",
|
||||
currency: "USD",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects lowercase currency", { timeout: 5000 }, () => {
|
||||
const result = MoneySchema.safeParse({ amount: 1n, currency: "usd" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects short currency", { timeout: 5000 }, () => {
|
||||
const result = MoneySchema.safeParse({ amount: 1n, currency: "U" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts 8-char currency such as LAMPORTS", { timeout: 5000 }, () => {
|
||||
const parsed = MoneySchema.parse({
|
||||
amount: 1n,
|
||||
currency: "LAMPORTS",
|
||||
});
|
||||
expect(parsed.currency).toBe("LAMPORTS");
|
||||
});
|
||||
|
||||
it("rejects amounts of unknown shape", { timeout: 5000 }, () => {
|
||||
const result = MoneySchema.safeParse({
|
||||
amount: { weird: 1 },
|
||||
currency: "USD",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Boundary schema for `MoneyLike`. Amount is `bigint` (minor units —
|
||||
* cents, pence, satoshi, lamports). JSON cannot natively carry bigints,
|
||||
* so the schema accepts either a JSON-number, a numeric string, or a
|
||||
* bigint literal and coerces via `BigInt()`. Currency is a 3-8 char
|
||||
* ISO 4217 code (or caller-registered identifier — `BTC`, `USDC`,
|
||||
* `LAMPORTS`).
|
||||
*/
|
||||
export const MoneySchema = z.object({
|
||||
amount: z.preprocess((val) => {
|
||||
if (typeof val === "bigint") return val;
|
||||
if (typeof val === "number" && Number.isFinite(val)) {
|
||||
return BigInt(Math.trunc(val));
|
||||
}
|
||||
if (typeof val === "string" && /^-?\d+$/.test(val)) {
|
||||
return BigInt(val);
|
||||
}
|
||||
return val;
|
||||
}, z.bigint().nonnegative()),
|
||||
currency: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(8)
|
||||
.regex(/^[A-Z0-9]+$/u, "currency must be uppercase letters or digits"),
|
||||
});
|
||||
|
||||
export type Money = z.infer<typeof MoneySchema>;
|
||||
@@ -0,0 +1,50 @@
|
||||
# options
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schema validating `PaymentPluginOptions` at the `createPaymentPlugin` factory boundary. The schema runs once at construction time and rejects malformed input fast with a structured `ZodError` chained as the `cause` of a `PaymentPluginError(INVALID_INPUT)`. The runtime objects (`PaymentProvider`, `PaymentIntentStore`, `LoggerLike`, `Clock`) are validated structurally — every method exists, `.name` is a string — because their internals are out of scope for boundary validation.
|
||||
|
||||
This module pairs with `src/types/types.ts` (the TypeScript surface) so the inferred output shape is identical to `PaymentPluginConfig`. The factory parses, casts to `PaymentPluginConfig`, and passes the resolved value into `definePlugin<PaymentPluginConfig>(...)` — every downstream module sees defaults applied.
|
||||
|
||||
## Features
|
||||
|
||||
- `PaymentPluginOptionsSchema` validates `provider` (structural), `webhookSecret` (≥8 chars), `routePrefix` (default `/payments`), `webhookToleranceMs` (default `300_000`), `webhookAlgorithm` (default `SHA-256`), optional `store`/`logger`/`clock`.
|
||||
- Structural validators reject bare objects that are missing required methods — provider missing `refund` fails before the plugin ever calls it.
|
||||
- Inferred input/output types are surfaced separately so callers can distinguish "what you pass in" from "what you get out after defaults apply".
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { PaymentPluginOptionsSchema } from "@suaveplan/api-plugin-payment/schemas/options";
|
||||
|
||||
const config = PaymentPluginOptionsSchema.parse({
|
||||
provider: new TestPaymentProvider(),
|
||||
webhookSecret: "shhh-this-is-long-enough",
|
||||
});
|
||||
// config.routePrefix === "/payments"
|
||||
// config.webhookToleranceMs === 300_000
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const result = PaymentPluginOptionsSchema.safeParse({
|
||||
provider: bogus, // missing methods
|
||||
webhookSecret: "short",
|
||||
});
|
||||
if (!result.success) {
|
||||
for (const issue of result.error.issues) {
|
||||
console.error(issue.path.join("."), issue.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PaymentPluginOptionsSchema` — `z.object({ provider, routePrefix?, store?, webhookSecret, webhookToleranceMs?, webhookAlgorithm?, logger?, clock? })`.
|
||||
- `PaymentPluginOptionsInput` — the pre-default input type.
|
||||
- `PaymentPluginOptionsResolved` — the post-default output type.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`webhookSecret` is enforced at ≥8 chars — short enough to accept test-only secrets but long enough to reject obviously-empty strings. Production deployments should use ≥32 chars of high-entropy random data; that requirement is documented in the README but not enforced at the schema layer because some providers (e.g. test environments) issue shorter signing keys and the plugin should not refuse to start in those modes.
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MemoryPaymentIntentStore } from "../store/memory-store.js";
|
||||
import { PaymentPluginOptionsSchema } from "./options.js";
|
||||
|
||||
function makeProvider() {
|
||||
return {
|
||||
name: "test",
|
||||
createIntent: async () => ({}),
|
||||
confirmIntent: async () => ({}),
|
||||
captureIntent: async () => ({}),
|
||||
cancelIntent: async () => ({}),
|
||||
refund: async () => ({}),
|
||||
getIntent: async () => ({}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("PaymentPluginOptionsSchema", () => {
|
||||
it("accepts a minimal valid config", { timeout: 5000 }, () => {
|
||||
const result = PaymentPluginOptionsSchema.safeParse({
|
||||
provider: makeProvider(),
|
||||
webhookSecret: "long-enough-secret",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.routePrefix).toBe("/payments");
|
||||
expect(result.data.webhookToleranceMs).toBe(300_000);
|
||||
expect(result.data.webhookAlgorithm).toBe("SHA-256");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects missing webhookSecret", { timeout: 5000 }, () => {
|
||||
const result = PaymentPluginOptionsSchema.safeParse({
|
||||
provider: makeProvider(),
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects short webhookSecret", { timeout: 5000 }, () => {
|
||||
const result = PaymentPluginOptionsSchema.safeParse({
|
||||
provider: makeProvider(),
|
||||
webhookSecret: "short",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects provider missing refund method", { timeout: 5000 }, () => {
|
||||
const bad = makeProvider() as Record<string, unknown>;
|
||||
delete bad["refund"];
|
||||
const result = PaymentPluginOptionsSchema.safeParse({
|
||||
provider: bad,
|
||||
webhookSecret: "long-enough-secret",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects provider missing name", { timeout: 5000 }, () => {
|
||||
const bad = makeProvider() as Record<string, unknown>;
|
||||
delete bad["name"];
|
||||
const result = PaymentPluginOptionsSchema.safeParse({
|
||||
provider: bad,
|
||||
webhookSecret: "long-enough-secret",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects trailing-slash routePrefix", { timeout: 5000 }, () => {
|
||||
const result = PaymentPluginOptionsSchema.safeParse({
|
||||
provider: makeProvider(),
|
||||
webhookSecret: "long-enough-secret",
|
||||
routePrefix: "/payments/",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts custom routePrefix and root prefix", { timeout: 5000 }, () => {
|
||||
const a = PaymentPluginOptionsSchema.parse({
|
||||
provider: makeProvider(),
|
||||
webhookSecret: "long-enough-secret",
|
||||
routePrefix: "/billing",
|
||||
});
|
||||
expect(a.routePrefix).toBe("/billing");
|
||||
const b = PaymentPluginOptionsSchema.parse({
|
||||
provider: makeProvider(),
|
||||
webhookSecret: "long-enough-secret",
|
||||
routePrefix: "/",
|
||||
});
|
||||
expect(b.routePrefix).toBe("/");
|
||||
});
|
||||
|
||||
it("accepts a real PaymentIntentStore", { timeout: 5000 }, () => {
|
||||
const store = new MemoryPaymentIntentStore();
|
||||
const result = PaymentPluginOptionsSchema.parse({
|
||||
provider: makeProvider(),
|
||||
webhookSecret: "long-enough-secret",
|
||||
store,
|
||||
});
|
||||
expect(result.store).toBe(store);
|
||||
});
|
||||
|
||||
it("rejects a bogus store (missing methods)", { timeout: 5000 }, () => {
|
||||
const result = PaymentPluginOptionsSchema.safeParse({
|
||||
provider: makeProvider(),
|
||||
webhookSecret: "long-enough-secret",
|
||||
store: { put: () => undefined },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts SHA-384 / SHA-512 algorithm overrides", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r1 = PaymentPluginOptionsSchema.parse({
|
||||
provider: makeProvider(),
|
||||
webhookSecret: "long-enough-secret",
|
||||
webhookAlgorithm: "SHA-384",
|
||||
});
|
||||
const r2 = PaymentPluginOptionsSchema.parse({
|
||||
provider: makeProvider(),
|
||||
webhookSecret: "long-enough-secret",
|
||||
webhookAlgorithm: "SHA-512",
|
||||
});
|
||||
expect(r1.webhookAlgorithm).toBe("SHA-384");
|
||||
expect(r2.webhookAlgorithm).toBe("SHA-512");
|
||||
});
|
||||
|
||||
it("rejects unknown algorithm", { timeout: 5000 }, () => {
|
||||
const result = PaymentPluginOptionsSchema.safeParse({
|
||||
provider: makeProvider(),
|
||||
webhookSecret: "long-enough-secret",
|
||||
webhookAlgorithm: "MD5",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects negative toleranceMs", { timeout: 5000 }, () => {
|
||||
const result = PaymentPluginOptionsSchema.safeParse({
|
||||
provider: makeProvider(),
|
||||
webhookSecret: "long-enough-secret",
|
||||
webhookToleranceMs: -1,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts logger + clock overrides", { timeout: 5000 }, () => {
|
||||
const result = PaymentPluginOptionsSchema.parse({
|
||||
provider: makeProvider(),
|
||||
webhookSecret: "long-enough-secret",
|
||||
logger: {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
},
|
||||
clock: () => 42,
|
||||
});
|
||||
expect(result.logger).toBeDefined();
|
||||
expect(result.clock?.()).toBe(42);
|
||||
});
|
||||
|
||||
it("rejects bogus logger missing info", { timeout: 5000 }, () => {
|
||||
const result = PaymentPluginOptionsSchema.safeParse({
|
||||
provider: makeProvider(),
|
||||
webhookSecret: "long-enough-secret",
|
||||
logger: { debug: () => undefined },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects bogus clock missing now", { timeout: 5000 }, () => {
|
||||
const result = PaymentPluginOptionsSchema.safeParse({
|
||||
provider: makeProvider(),
|
||||
webhookSecret: "long-enough-secret",
|
||||
clock: { broken: true } as never,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import z from "zod";
|
||||
|
||||
const ProviderSchema = z.custom<{
|
||||
readonly name: string;
|
||||
createIntent: (...args: readonly unknown[]) => unknown;
|
||||
confirmIntent: (...args: readonly unknown[]) => unknown;
|
||||
captureIntent: (...args: readonly unknown[]) => unknown;
|
||||
cancelIntent: (...args: readonly unknown[]) => unknown;
|
||||
refund: (...args: readonly unknown[]) => unknown;
|
||||
getIntent: (...args: readonly unknown[]) => unknown;
|
||||
}>(
|
||||
(val) =>
|
||||
val !== null &&
|
||||
typeof val === "object" &&
|
||||
typeof (val as { name?: unknown }).name === "string" &&
|
||||
typeof (val as { createIntent?: unknown }).createIntent ===
|
||||
"function" &&
|
||||
typeof (val as { confirmIntent?: unknown }).confirmIntent ===
|
||||
"function" &&
|
||||
typeof (val as { captureIntent?: unknown }).captureIntent ===
|
||||
"function" &&
|
||||
typeof (val as { cancelIntent?: unknown }).cancelIntent ===
|
||||
"function" &&
|
||||
typeof (val as { refund?: unknown }).refund === "function" &&
|
||||
typeof (val as { getIntent?: unknown }).getIntent === "function",
|
||||
"provider must implement the PaymentProvider interface"
|
||||
);
|
||||
|
||||
const StoreSchema = z.custom<{
|
||||
put: (...args: readonly unknown[]) => unknown;
|
||||
get: (...args: readonly unknown[]) => unknown;
|
||||
listByOwner: (...args: readonly unknown[]) => unknown;
|
||||
}>(
|
||||
(val) =>
|
||||
val !== null &&
|
||||
typeof val === "object" &&
|
||||
typeof (val as { put?: unknown }).put === "function" &&
|
||||
typeof (val as { get?: unknown }).get === "function" &&
|
||||
typeof (val as { listByOwner?: unknown }).listByOwner === "function",
|
||||
"store must implement PaymentIntentStore"
|
||||
);
|
||||
|
||||
const LoggerSchema = z.custom<{
|
||||
debug: (msg: string, ctx?: unknown) => void;
|
||||
info: (msg: string, ctx?: unknown) => void;
|
||||
warn: (msg: string, ctx?: unknown) => void;
|
||||
error: (msg: string, ctx?: unknown) => void;
|
||||
}>(
|
||||
(val) =>
|
||||
val !== null &&
|
||||
typeof val === "object" &&
|
||||
typeof (val as { info?: unknown }).info === "function",
|
||||
"logger must be a LoggerLike with debug/info/warn/error"
|
||||
);
|
||||
|
||||
const ClockSchema = z.custom<() => number>(
|
||||
(val) => typeof val === "function",
|
||||
"clock must be a () => number function"
|
||||
);
|
||||
|
||||
/**
|
||||
* Zod schema for `PaymentPluginOptions`. Validated by
|
||||
* `createPaymentPlugin` at the factory boundary — invalid input throws
|
||||
* `PaymentPluginError(INVALID_INPUT)` carrying the `ZodError` as cause.
|
||||
*
|
||||
* `provider` is validated structurally (every method exists, `.name` is
|
||||
* a string) because `PaymentProvider` is a runtime interface, not a
|
||||
* serialisable shape. The same treatment is applied to `store`,
|
||||
* `logger`, `clock`.
|
||||
*/
|
||||
export const PaymentPluginOptionsSchema = z.object({
|
||||
provider: ProviderSchema,
|
||||
routePrefix: z
|
||||
.string()
|
||||
.regex(/^\/[A-Za-z0-9\-_/]*[A-Za-z0-9\-_]$|^\/$/u, {
|
||||
message:
|
||||
"routePrefix must start with '/' and not end with '/' (unless it is exactly '/')",
|
||||
})
|
||||
.default("/payments"),
|
||||
store: StoreSchema.optional(),
|
||||
webhookSecret: z.string().min(8),
|
||||
webhookToleranceMs: z.number().int().positive().default(300_000),
|
||||
webhookAlgorithm: z
|
||||
.enum(["SHA-256", "SHA-384", "SHA-512"])
|
||||
.default("SHA-256"),
|
||||
logger: LoggerSchema.optional(),
|
||||
clock: ClockSchema.optional(),
|
||||
});
|
||||
|
||||
export type PaymentPluginOptionsInput = z.input<
|
||||
typeof PaymentPluginOptionsSchema
|
||||
>;
|
||||
export type PaymentPluginOptionsResolved = z.output<
|
||||
typeof PaymentPluginOptionsSchema
|
||||
>;
|
||||
@@ -0,0 +1,46 @@
|
||||
# capture-intent
|
||||
|
||||
## Purpose
|
||||
|
||||
Body schema for `POST /payments/intents/:id/capture`. Supports both full capture (empty body) and partial capture (body carries `amount: MoneySchema`). The partial-capture path is essential for two-phase authorise+capture flows such as hotel reservations where the final amount differs from the original authorisation, and for split-shipment e-commerce orders that bill at fulfilment.
|
||||
|
||||
The provider is the authoritative source of capture eligibility — the schema only enforces that the optional `amount` is a well-formed `Money` value if present, and lets the `PaymentService` reject business-rule violations (capturing more than authorised, capturing after refund, currency mismatch).
|
||||
|
||||
## Features
|
||||
|
||||
- `CaptureBodySchema` accepts an optional `amount: MoneySchema`.
|
||||
- Empty body → full-amount capture (request `{}`).
|
||||
- Inferred `CaptureBody` type plugs into Hono's typed body parser.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CaptureBodySchema } from "@suaveplan/api-plugin-payment/schemas/routes";
|
||||
|
||||
const body = CaptureBodySchema.parse({}); // full capture
|
||||
const partial = CaptureBodySchema.parse({
|
||||
amount: { amount: 1500n, currency: "USD" },
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
// Two-phase capture flow — authorise full, capture partial after fulfilment
|
||||
await fetch("/payments/intents/pi_abc/capture", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
amount: { amount: shippedItemsTotal, currency: "USD" },
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CaptureBodySchema` — `z.object({ amount?: MoneySchema })`.
|
||||
- `CaptureBody` — inferred TS type.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema allows an empty body for the full-capture path because forcing callers to send `{ amount: { amount: <originalAmount>, currency: <originalCurrency> } }` would require them to re-fetch the intent first — that's two round-trips for a happy-path full capture. Letting `amount` be optional shortens the common case to one request.
|
||||
@@ -0,0 +1,9 @@
|
||||
import z from "zod";
|
||||
import { MoneySchema } from "../money.js";
|
||||
|
||||
/** Body schema for `POST /payments/intents/:id/capture`. Empty body = full capture. */
|
||||
export const CaptureBodySchema = z.object({
|
||||
amount: MoneySchema.optional(),
|
||||
});
|
||||
|
||||
export type CaptureBody = z.infer<typeof CaptureBodySchema>;
|
||||
@@ -0,0 +1,56 @@
|
||||
# create-intent
|
||||
|
||||
## Purpose
|
||||
|
||||
Body and response schemas for `POST /payments/intents`. The request body carries the amount (typed `MoneySchema`), optional description, optional capture method (`automatic` | `manual`), and optional metadata map. The response envelope wraps a serialised `PaymentIntent` so the OpenAPI doc has a single canonical reference for the lifecycle's primary mutation.
|
||||
|
||||
This is the principal write endpoint for the plugin — every authenticated payment-creation flow calls it, and idempotency middleware (`Idempotency-Key`) wraps the route to replay identical second calls without re-charging the provider.
|
||||
|
||||
## Features
|
||||
|
||||
- `CreatePaymentIntentBodySchema` accepts `amount`, `description`, `captureMethod` (default `"automatic"`), and an optional string-to-string `metadata` map.
|
||||
- `PaymentIntentResponseSchema` wraps a single `PaymentIntentSchema` for type-safe consumer parsing.
|
||||
- All optional fields are strict — extra body keys are rejected via Zod's default-strict mode.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CreatePaymentIntentBodySchema } from "@suaveplan/api-plugin-payment/schemas/routes";
|
||||
|
||||
const body = CreatePaymentIntentBodySchema.parse({
|
||||
amount: { amount: 2500n, currency: "USD" },
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
CreatePaymentIntentBodySchema,
|
||||
PaymentIntentResponseSchema,
|
||||
} from "@suaveplan/api-plugin-payment/schemas/routes";
|
||||
|
||||
const body = CreatePaymentIntentBodySchema.parse({
|
||||
amount: { amount: "1999", currency: "EUR" },
|
||||
captureMethod: "manual",
|
||||
description: "Order #ORD-1234",
|
||||
metadata: { orderId: "ORD-1234", source: "checkout-web" },
|
||||
});
|
||||
const res = await fetch("/payments/intents", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "Idempotency-Key": orderId },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const parsed = PaymentIntentResponseSchema.parse(await res.json());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CreatePaymentIntentBodySchema` — `{ amount: MoneySchema, description?: string (1..500), captureMethod: "automatic" | "manual" (default "automatic"), metadata?: Record<string, string> }`.
|
||||
- `CreatePaymentIntentBody` — inferred TS type.
|
||||
- `PaymentIntentResponseSchema` — `{ intent: PaymentIntentSchema }`.
|
||||
- `PaymentIntentResponse` — inferred TS type.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`captureMethod` defaults to `"automatic"` because the dominant Stripe-style flow captures on confirm. `"manual"` exists for two-phase auth/capture flows (think hotel deposits / fuel pumps where the final amount differs from the authorisation). `description` is bounded to 500 chars to match Stripe's API limit and prevent unbounded metadata-driven storage costs downstream.
|
||||
@@ -0,0 +1,22 @@
|
||||
import z from "zod";
|
||||
import { MoneySchema } from "../money.js";
|
||||
import { PaymentIntentSchema } from "./payment-intent.js";
|
||||
|
||||
/** Body schema for `POST /payments/intents`. */
|
||||
export const CreatePaymentIntentBodySchema = z.object({
|
||||
amount: MoneySchema,
|
||||
description: z.string().min(1).max(500).optional(),
|
||||
captureMethod: z.enum(["automatic", "manual"]).default("automatic"),
|
||||
metadata: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export type CreatePaymentIntentBody = z.infer<
|
||||
typeof CreatePaymentIntentBodySchema
|
||||
>;
|
||||
|
||||
/** Response schema — typed PaymentIntent wrapper. */
|
||||
export const PaymentIntentResponseSchema = z.object({
|
||||
intent: PaymentIntentSchema,
|
||||
});
|
||||
|
||||
export type PaymentIntentResponse = z.infer<typeof PaymentIntentResponseSchema>;
|
||||
@@ -0,0 +1,42 @@
|
||||
# empty-body
|
||||
|
||||
## Purpose
|
||||
|
||||
Strict empty-object body schema shared by `POST /payments/intents/:id/confirm` and `POST /payments/intents/:id/cancel`. Both routes take their operand from the `:id` path parameter — there's no per-request input beyond the path. Hono/OpenAPI's body parser still requires a typed schema for documented routes, so this module supplies one explicit schema both routes import.
|
||||
|
||||
Using a strict empty object (`z.object({}).strict()`) rather than `z.never()` or skipping the body schema entirely gives Hono a real validator to attach, ensures the OpenAPI doc lists `application/json` with an empty `properties: {}` object (and not `null`), and rejects accidental extra fields — a stray `{ amount: ... }` body on the confirm route would silently be ignored if the schema accepted unknowns, masking client-side bugs.
|
||||
|
||||
## Features
|
||||
|
||||
- `EmptyBodySchema` accepts `{}` only — rejects every extra key via `.strict()`.
|
||||
- Single import point so the confirm/cancel routes share one validator.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { EmptyBodySchema } from "@suaveplan/api-plugin-payment/schemas/routes";
|
||||
|
||||
EmptyBodySchema.parse({}); // ok
|
||||
const bad = EmptyBodySchema.safeParse({ amount: 1 });
|
||||
bad.success; // false
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
// confirm route — typed empty body
|
||||
const confirm = POST("/intents/{id}/confirm")
|
||||
.params(PaymentIntentIdParamSchema)
|
||||
.body(EmptyBodySchema)
|
||||
.response(successResponse(PaymentIntentResponseSchema))
|
||||
.build();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `EmptyBodySchema` — `z.object({}).strict()`.
|
||||
- `EmptyBody` — inferred TS type (`Record<string, never>`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Calling `.strict()` is important — Zod's default object behaviour is `passthrough` only with explicit `.passthrough()`, but in current Zod versions extra keys are silently stripped. `.strict()` raises a `ZodError` on extras so client bugs surface immediately. The `EmptyBody` type infers to `Record<string, never>` which TypeScript surfaces as `{}` at use sites — the right ergonomics for a route that takes no body.
|
||||
@@ -0,0 +1,6 @@
|
||||
import z from "zod";
|
||||
|
||||
/** Empty-body schema for confirm/cancel actions. */
|
||||
export const EmptyBodySchema = z.object({}).strict();
|
||||
|
||||
export type EmptyBody = z.infer<typeof EmptyBodySchema>;
|
||||
@@ -0,0 +1,39 @@
|
||||
export {
|
||||
type CaptureBody,
|
||||
CaptureBodySchema,
|
||||
} from "./capture-intent.js";
|
||||
export {
|
||||
type CreatePaymentIntentBody,
|
||||
CreatePaymentIntentBodySchema,
|
||||
type PaymentIntentResponse,
|
||||
PaymentIntentResponseSchema,
|
||||
} from "./create-intent.js";
|
||||
export { type EmptyBody, EmptyBodySchema } from "./empty-body.js";
|
||||
export {
|
||||
type PaymentIntentIdParam,
|
||||
PaymentIntentIdParamSchema,
|
||||
} from "./intent-id-param.js";
|
||||
export {
|
||||
type ListPaymentIntentsQuery,
|
||||
ListPaymentIntentsQuerySchema,
|
||||
type PaymentIntentListResponse,
|
||||
PaymentIntentListResponseSchema,
|
||||
} from "./list-intents.js";
|
||||
export {
|
||||
type PaymentIntentEnvelope,
|
||||
PaymentIntentSchema,
|
||||
type PaymentStatusName,
|
||||
PaymentStatusSchema,
|
||||
type RefundEnvelope,
|
||||
RefundSchema,
|
||||
} from "./payment-intent.js";
|
||||
export {
|
||||
type RefundBody,
|
||||
RefundBodySchema,
|
||||
type RefundResponse,
|
||||
RefundResponseSchema,
|
||||
} from "./refund.js";
|
||||
export {
|
||||
type WebhookResponse,
|
||||
WebhookResponseSchema,
|
||||
} from "./webhook.js";
|
||||
@@ -0,0 +1,39 @@
|
||||
# intent-id-param
|
||||
|
||||
## Purpose
|
||||
|
||||
Schema for the `:id` path parameter shared by every per-intent route (`GET /intents/:id`, `POST /intents/:id/confirm`, `POST /intents/:id/capture`, `POST /intents/:id/cancel`, `POST /intents/:id/refund`). The validator enforces a 1..255 character non-empty string so callers can't supply empty or absurdly-long segments that would blow up downstream provider lookups.
|
||||
|
||||
The bound is deliberately loose — different providers issue different id formats (Stripe's `pi_*` is 27 chars, PayPal's UUIDs are 36, custom adapters may emit anything in between) so the schema avoids a regex constraint and leaves precise format-checking to the provider's own SDK. The validation that matters is at the ownership layer (`PaymentService.getIntent` checks the store) — the param schema is the cheap first gate against obviously-bogus input.
|
||||
|
||||
## Features
|
||||
|
||||
- `PaymentIntentIdParamSchema` accepts a single `id` string of 1..255 chars.
|
||||
- Inferred type `PaymentIntentIdParam` plugs into Hono's typed path-param surface.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { PaymentIntentIdParamSchema } from "@suaveplan/api-plugin-payment/schemas/routes";
|
||||
|
||||
const params = PaymentIntentIdParamSchema.parse({ id: "pi_abc123" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
app.get("/intents/:id", async (c) => {
|
||||
const { id } = PaymentIntentIdParamSchema.parse(c.req.param());
|
||||
const intent = await service.getIntent({ id, ownerId: c.get("user")!.id });
|
||||
return c.json({ intent });
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PaymentIntentIdParamSchema` — `z.object({ id: z.string().min(1).max(255) })`.
|
||||
- `PaymentIntentIdParam` — inferred TS type.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The 255 char ceiling matches the maximum length of a single DNS label, which is more headroom than any real provider id format will ever consume. Tightening the regex (e.g. `/^[A-Za-z0-9_-]+$/`) would block real provider ids that use periods or slashes in their hierarchical id scheme — Adyen's PSP reference, for instance, has historically included colons. Leaving the validator format-agnostic prevents that brittleness.
|
||||
@@ -0,0 +1,8 @@
|
||||
import z from "zod";
|
||||
|
||||
/** Path param schema — `:id` carries a provider-issued intent id. */
|
||||
export const PaymentIntentIdParamSchema = z.object({
|
||||
id: z.string().min(1).max(255),
|
||||
});
|
||||
|
||||
export type PaymentIntentIdParam = z.infer<typeof PaymentIntentIdParamSchema>;
|
||||
@@ -0,0 +1,44 @@
|
||||
# list-intents
|
||||
|
||||
## Purpose
|
||||
|
||||
Query and response schemas for `GET /payments/intents`. The route lists intents owned by the authenticated principal, optionally filtered by `status`. The query schema coerces `limit` from a URL-string into a number (1..100, default 20) and accepts any of the 9 `PaymentStatus` values for the optional `status` filter.
|
||||
|
||||
The response wraps an array of `PaymentIntentSchema` — pagination over a cursor is deferred to a follow-up change because the in-memory default store is not large enough to require it, and persistent stores (SurrealDB / Postgres) implement their own efficient cursor schemes that the plugin would only marshal through.
|
||||
|
||||
## Features
|
||||
|
||||
- `limit` coerces from URL-string to number with sensible bounds (1..100) and a default of 20.
|
||||
- `status` optional — when present narrows to a single lifecycle state.
|
||||
- Response is a flat `{ intents: [...] }` envelope; cursor pagination is layered on by the eventual `api-plugin-payment-pagination` follow-up.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ListPaymentIntentsQuerySchema } from "@suaveplan/api-plugin-payment/schemas/routes";
|
||||
|
||||
const query = ListPaymentIntentsQuerySchema.parse({});
|
||||
// { limit: 20 }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const res = await fetch(
|
||||
"/payments/intents?" + new URLSearchParams({ limit: "50", status: "captured" })
|
||||
);
|
||||
const { intents } = PaymentIntentListResponseSchema.parse(await res.json());
|
||||
for (const intent of intents) {
|
||||
console.log(intent.id, intent.amount.amount, intent.amount.currency);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ListPaymentIntentsQuerySchema` — `{ limit: number (coerced, 1..100, default 20), status?: PaymentStatus }`.
|
||||
- `ListPaymentIntentsQuery` — inferred TS type.
|
||||
- `PaymentIntentListResponseSchema` — `{ intents: PaymentIntentSchema[] }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`limit` defaults to 20 because that matches the default of every dashboard view that drives this endpoint — Stripe Dashboard's per-page count is 20, and applications typically paginate at the same granularity to avoid unnecessary round-trips for "show me the latest activity". The `100` ceiling is a safety net against accidental `?limit=10000` requests that would page the entire row set in one query.
|
||||
@@ -0,0 +1,21 @@
|
||||
import z from "zod";
|
||||
import { PaymentIntentSchema, PaymentStatusSchema } from "./payment-intent.js";
|
||||
|
||||
/** Query schema for `GET /payments/intents`. */
|
||||
export const ListPaymentIntentsQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
status: PaymentStatusSchema.optional(),
|
||||
});
|
||||
|
||||
export type ListPaymentIntentsQuery = z.infer<
|
||||
typeof ListPaymentIntentsQuerySchema
|
||||
>;
|
||||
|
||||
/** Response shape for the list route. */
|
||||
export const PaymentIntentListResponseSchema = z.object({
|
||||
intents: z.array(PaymentIntentSchema),
|
||||
});
|
||||
|
||||
export type PaymentIntentListResponse = z.infer<
|
||||
typeof PaymentIntentListResponseSchema
|
||||
>;
|
||||
@@ -0,0 +1,48 @@
|
||||
# payment-intent
|
||||
|
||||
## Purpose
|
||||
|
||||
Wire-shape Zod schemas for the serialised `PaymentIntent`, `Refund`, and `PaymentStatus` types exposed by every route in `@suaveplan/api-plugin-payment`. These schemas are the canonical contract between the server and the API consumer — every response body matches `PaymentIntentSchema` or `RefundSchema`, and every OpenAPI 3.1 document the host emits at `/openapi.json` references them via `$ref`.
|
||||
|
||||
The schemas mirror `@suaveplan/payment`'s `PaymentIntent`/`Refund`/`PaymentStatus` types verbatim except for the `amount` fields, which use the local `MoneySchema` so `bigint` values are accepted as JSON-numbers or numeric strings on the wire (JSON cannot natively encode `bigint`).
|
||||
|
||||
## Features
|
||||
|
||||
- `PaymentStatusSchema` — Zod enum of all 9 lifecycle states.
|
||||
- `RefundSchema` — full refund-record shape including `reason` and `metadata`.
|
||||
- `PaymentIntentSchema` — top-level intent including captured/refunded balances, refund history, and `captureMethod`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { PaymentIntentSchema } from "@suaveplan/api-plugin-payment/schemas/routes";
|
||||
|
||||
const wire = PaymentIntentSchema.parse(await response.json());
|
||||
console.log(wire.status, wire.amount.amount);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
PaymentIntentSchema,
|
||||
PaymentStatusSchema,
|
||||
RefundSchema,
|
||||
} from "@suaveplan/api-plugin-payment/schemas/routes";
|
||||
|
||||
const Decoded = PaymentIntentSchema.extend({
|
||||
// app-specific extensions:
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
const enriched = Decoded.parse(await response.json());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PaymentStatusSchema` — `z.enum(["created", "pending", "authorized", "captured", "partially_refunded", "refunded", "disputed", "canceled", "failed"])`.
|
||||
- `RefundSchema` — `{ id, paymentIntentId, amount, status, reason?, createdAt, metadata? }`.
|
||||
- `PaymentIntentSchema` — `{ id, status, amount, capturedAmount, refundedAmount, captureMethod, description?, refunds[], metadata?, createdAt, updatedAt }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`PaymentIntentSchema` omits `paymentMethod` and `dispute` for now — both are scoped to follow-up plugins (`api-plugin-payment-methods`, `api-plugin-payment-disputes`). The omission keeps the surface narrow without precluding extensions: downstream consumers can `PaymentIntentSchema.extend({...})` to layer fields onto the wire shape without forcing this package to track every provider's bespoke metadata.
|
||||
@@ -0,0 +1,49 @@
|
||||
import z from "zod";
|
||||
import { MoneySchema } from "../money.js";
|
||||
|
||||
/** Status enum mirroring `@suaveplan/payment`'s `PaymentStatus`. */
|
||||
export const PaymentStatusSchema = z.enum([
|
||||
"created",
|
||||
"pending",
|
||||
"authorized",
|
||||
"captured",
|
||||
"partially_refunded",
|
||||
"refunded",
|
||||
"disputed",
|
||||
"canceled",
|
||||
"failed",
|
||||
]);
|
||||
|
||||
export type PaymentStatusName = z.infer<typeof PaymentStatusSchema>;
|
||||
|
||||
/** Refund record schema. */
|
||||
export const RefundSchema = z.object({
|
||||
id: z.string(),
|
||||
paymentIntentId: z.string(),
|
||||
amount: MoneySchema,
|
||||
status: z.enum(["pending", "succeeded", "failed"]),
|
||||
reason: z
|
||||
.enum(["duplicate", "fraudulent", "requested_by_customer", "other"])
|
||||
.optional(),
|
||||
createdAt: z.string(),
|
||||
metadata: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export type RefundEnvelope = z.infer<typeof RefundSchema>;
|
||||
|
||||
/** Wire shape of a serialised `PaymentIntent`. */
|
||||
export const PaymentIntentSchema = z.object({
|
||||
id: z.string(),
|
||||
status: PaymentStatusSchema,
|
||||
amount: MoneySchema,
|
||||
capturedAmount: MoneySchema,
|
||||
refundedAmount: MoneySchema,
|
||||
captureMethod: z.enum(["automatic", "manual"]),
|
||||
description: z.string().optional(),
|
||||
refunds: z.array(RefundSchema),
|
||||
metadata: z.record(z.string(), z.string()).optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
export type PaymentIntentEnvelope = z.infer<typeof PaymentIntentSchema>;
|
||||
@@ -0,0 +1,55 @@
|
||||
# refund
|
||||
|
||||
## Purpose
|
||||
|
||||
Body + response schemas for `POST /payments/intents/:id/refund`. Supports full or partial refunds with optional `reason` (`duplicate` | `fraudulent` | `requested_by_customer` | `other`) and arbitrary string-keyed metadata. The response envelope wraps the issued `Refund` record so consumers can inspect the provider-issued refund id and the current status (`pending` | `succeeded` | `failed`).
|
||||
|
||||
Refunds are state-transitioning operations that move the intent's `refundedAmount` upward and may transition `status` from `captured` → `partially_refunded` or `partially_refunded` → `refunded`. The route MUST be wrapped with the `Idempotency-Key` middleware because retrying a refund without idempotency would double-refund the buyer — a compliance defect across every payment processor.
|
||||
|
||||
## Features
|
||||
|
||||
- `RefundBodySchema` accepts optional `amount` (omitted → full remaining-captured refund), optional `reason`, and optional `metadata` map.
|
||||
- `RefundResponseSchema` wraps a single `RefundSchema` for type-safe parsing.
|
||||
- Reason enum matches Stripe's documented set verbatim so providers can pass it through without translation.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { RefundBodySchema } from "@suaveplan/api-plugin-payment/schemas/routes";
|
||||
|
||||
const body = RefundBodySchema.parse({ reason: "requested_by_customer" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
RefundBodySchema,
|
||||
RefundResponseSchema,
|
||||
} from "@suaveplan/api-plugin-payment/schemas/routes";
|
||||
|
||||
const res = await fetch(`/payments/intents/${intentId}/refund`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"Idempotency-Key": `refund-${orderId}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount: { amount: 1000n, currency: "USD" },
|
||||
reason: "duplicate",
|
||||
metadata: { ticketId: "TCK-9981" },
|
||||
} satisfies RefundBody),
|
||||
});
|
||||
const { refund } = RefundResponseSchema.parse(await res.json());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RefundBodySchema` — `{ amount?: MoneySchema, reason?: "duplicate" | "fraudulent" | "requested_by_customer" | "other", metadata?: Record<string,string> }`.
|
||||
- `RefundBody` — inferred TS type.
|
||||
- `RefundResponseSchema` — `{ refund: RefundSchema }`.
|
||||
- `RefundResponse` — inferred TS type.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `amount` field is optional rather than defaulting to the full captured balance because providers vary in how they handle the "refund whatever is left" semantic — Stripe accepts `{ amount: undefined }` cleanly, PayPal requires explicit amounts on most flows. Letting it be optional surfaces the same shape across both and lets the `PaymentService.refund` impl translate the absence into whatever each provider needs.
|
||||
@@ -0,0 +1,21 @@
|
||||
import z from "zod";
|
||||
import { MoneySchema } from "../money.js";
|
||||
import { RefundSchema } from "./payment-intent.js";
|
||||
|
||||
/** Body schema for `POST /payments/intents/:id/refund`. */
|
||||
export const RefundBodySchema = z.object({
|
||||
amount: MoneySchema.optional(),
|
||||
reason: z
|
||||
.enum(["duplicate", "fraudulent", "requested_by_customer", "other"])
|
||||
.optional(),
|
||||
metadata: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export type RefundBody = z.infer<typeof RefundBodySchema>;
|
||||
|
||||
/** Response shape for the refund route. */
|
||||
export const RefundResponseSchema = z.object({
|
||||
refund: RefundSchema,
|
||||
});
|
||||
|
||||
export type RefundResponse = z.infer<typeof RefundResponseSchema>;
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
CaptureBodySchema,
|
||||
CreatePaymentIntentBodySchema,
|
||||
EmptyBodySchema,
|
||||
ListPaymentIntentsQuerySchema,
|
||||
PaymentIntentIdParamSchema,
|
||||
PaymentIntentListResponseSchema,
|
||||
PaymentIntentResponseSchema,
|
||||
PaymentIntentSchema,
|
||||
PaymentStatusSchema,
|
||||
RefundBodySchema,
|
||||
RefundResponseSchema,
|
||||
RefundSchema,
|
||||
WebhookResponseSchema,
|
||||
} from "./index.js";
|
||||
|
||||
const SAMPLE_INTENT = {
|
||||
id: "pi_abc",
|
||||
status: "captured" as const,
|
||||
amount: { amount: 1000n, currency: "USD" },
|
||||
capturedAmount: { amount: 1000n, currency: "USD" },
|
||||
refundedAmount: { amount: 0n, currency: "USD" },
|
||||
captureMethod: "automatic" as const,
|
||||
refunds: [],
|
||||
createdAt: "2026-05-19T00:00:00Z",
|
||||
updatedAt: "2026-05-19T00:00:00Z",
|
||||
};
|
||||
|
||||
describe("route schemas", () => {
|
||||
it("CreatePaymentIntentBodySchema accepts a minimal valid body", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parsed = CreatePaymentIntentBodySchema.parse({
|
||||
amount: { amount: 999n, currency: "USD" },
|
||||
});
|
||||
expect(parsed.captureMethod).toBe("automatic");
|
||||
});
|
||||
|
||||
it("CreatePaymentIntentBodySchema rejects oversized descriptions", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = CreatePaymentIntentBodySchema.safeParse({
|
||||
amount: { amount: 1n, currency: "USD" },
|
||||
description: "x".repeat(501),
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("CaptureBodySchema accepts an empty body", { timeout: 5000 }, () => {
|
||||
const parsed = CaptureBodySchema.parse({});
|
||||
expect(parsed.amount).toBeUndefined();
|
||||
});
|
||||
|
||||
it("CaptureBodySchema accepts a partial amount", { timeout: 5000 }, () => {
|
||||
const parsed = CaptureBodySchema.parse({
|
||||
amount: { amount: 500n, currency: "USD" },
|
||||
});
|
||||
expect(parsed.amount?.amount).toBe(500n);
|
||||
});
|
||||
|
||||
it("EmptyBodySchema rejects extra keys", { timeout: 5000 }, () => {
|
||||
expect(EmptyBodySchema.safeParse({}).success).toBe(true);
|
||||
expect(EmptyBodySchema.safeParse({ extra: 1 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("PaymentIntentIdParamSchema rejects empty ids", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(PaymentIntentIdParamSchema.safeParse({ id: "" }).success).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("PaymentIntentIdParamSchema rejects oversized ids", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
PaymentIntentIdParamSchema.safeParse({ id: "x".repeat(256) })
|
||||
.success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("ListPaymentIntentsQuerySchema defaults limit to 20", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const q = ListPaymentIntentsQuerySchema.parse({});
|
||||
expect(q.limit).toBe(20);
|
||||
});
|
||||
|
||||
it("ListPaymentIntentsQuerySchema coerces limit from string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const q = ListPaymentIntentsQuerySchema.parse({ limit: "50" });
|
||||
expect(q.limit).toBe(50);
|
||||
});
|
||||
|
||||
it("ListPaymentIntentsQuerySchema rejects limit > 100", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
ListPaymentIntentsQuerySchema.safeParse({ limit: "1000" }).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("ListPaymentIntentsQuerySchema accepts status filter", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const q = ListPaymentIntentsQuerySchema.parse({ status: "captured" });
|
||||
expect(q.status).toBe("captured");
|
||||
});
|
||||
|
||||
it("PaymentStatusSchema rejects unknown states", { timeout: 5000 }, () => {
|
||||
expect(PaymentStatusSchema.safeParse("nope").success).toBe(false);
|
||||
});
|
||||
|
||||
it("PaymentIntentSchema parses a full intent envelope", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parsed = PaymentIntentSchema.parse(SAMPLE_INTENT);
|
||||
expect(parsed.id).toBe("pi_abc");
|
||||
});
|
||||
|
||||
it("PaymentIntentResponseSchema wraps an intent", { timeout: 5000 }, () => {
|
||||
const parsed = PaymentIntentResponseSchema.parse({
|
||||
intent: SAMPLE_INTENT,
|
||||
});
|
||||
expect(parsed.intent.status).toBe("captured");
|
||||
});
|
||||
|
||||
it("PaymentIntentListResponseSchema accepts an empty list", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parsed = PaymentIntentListResponseSchema.parse({ intents: [] });
|
||||
expect(parsed.intents).toEqual([]);
|
||||
});
|
||||
|
||||
it("RefundBodySchema accepts an empty body", { timeout: 5000 }, () => {
|
||||
const parsed = RefundBodySchema.parse({});
|
||||
expect(parsed.reason).toBeUndefined();
|
||||
});
|
||||
|
||||
it("RefundBodySchema accepts every reason", { timeout: 5000 }, () => {
|
||||
for (const reason of [
|
||||
"duplicate",
|
||||
"fraudulent",
|
||||
"requested_by_customer",
|
||||
"other",
|
||||
] as const) {
|
||||
expect(RefundBodySchema.safeParse({ reason }).success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("RefundResponseSchema wraps a refund", { timeout: 5000 }, () => {
|
||||
const refund = {
|
||||
id: "re_1",
|
||||
paymentIntentId: "pi_abc",
|
||||
amount: { amount: 1n, currency: "USD" },
|
||||
status: "succeeded" as const,
|
||||
createdAt: "2026-05-19T00:00:00Z",
|
||||
};
|
||||
const parsed = RefundResponseSchema.parse({ refund });
|
||||
expect(parsed.refund.id).toBe("re_1");
|
||||
});
|
||||
|
||||
it("RefundSchema rejects unknown status", { timeout: 5000 }, () => {
|
||||
const bad = {
|
||||
id: "re_1",
|
||||
paymentIntentId: "pi_abc",
|
||||
amount: { amount: 1n, currency: "USD" },
|
||||
status: "weird",
|
||||
createdAt: "2026-05-19T00:00:00Z",
|
||||
};
|
||||
expect(RefundSchema.safeParse(bad).success).toBe(false);
|
||||
});
|
||||
|
||||
it("WebhookResponseSchema accepts accepted true/false", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(WebhookResponseSchema.parse({ accepted: true }).accepted).toBe(
|
||||
true
|
||||
);
|
||||
expect(WebhookResponseSchema.parse({ accepted: false }).accepted).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
# webhook
|
||||
|
||||
## Purpose
|
||||
|
||||
Response schema for `POST /payments/webhook`. The webhook receiver MUST read the raw request body and verify the provider HMAC BEFORE deserialising — there's no body schema because the route does not parse JSON at the Zod boundary; instead the receiver hands the raw bytes to `WebhookVerifier.verify` from `@suaveplan/payment`.
|
||||
|
||||
The response is intentionally minimal: an `accepted: boolean` flag the provider's webhook delivery infrastructure can use to decide whether to retry. A `true` response means "we received and authenticated the payload"; a `false` response (returned from the rejection path) indicates the request was rejected for a non-retryable reason (signature mismatch) and the provider should not retry.
|
||||
|
||||
## Features
|
||||
|
||||
- `WebhookResponseSchema` carries one field: `accepted: boolean`.
|
||||
- Schema is small by design — the wire surface for webhooks is the request body (provider-defined), not the response.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { WebhookResponseSchema } from "@suaveplan/api-plugin-payment/schemas/routes";
|
||||
|
||||
const wire = WebhookResponseSchema.parse({ accepted: true });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
// Provider-side delivery loop:
|
||||
const res = await fetch(`${endpoint}/payments/webhook`, {
|
||||
method: "POST",
|
||||
headers: { "X-Signature": sig, "X-Timestamp": String(ts) },
|
||||
body,
|
||||
});
|
||||
const { accepted } = WebhookResponseSchema.parse(await res.json());
|
||||
if (!accepted) {
|
||||
// signature failure — do NOT retry, alert on dashboard instead
|
||||
alertOps({ event: "webhook_rejected" });
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `WebhookResponseSchema` — `z.object({ accepted: z.boolean() })`.
|
||||
- `WebhookResponse` — inferred TS type.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The receiver does not surface the verified payload back to the provider in the response — payment providers neither inspect nor care about the response body beyond the status code. Returning a minimal `{ accepted }` shape lets operators distinguish authenticated-rejected (`200 { accepted: false }`) from total-failure (`5xx`) in their delivery logs without leaking sensitive internal state.
|
||||
@@ -0,0 +1,8 @@
|
||||
import z from "zod";
|
||||
|
||||
/** Response shape for the webhook receiver. */
|
||||
export const WebhookResponseSchema = z.object({
|
||||
accepted: z.boolean(),
|
||||
});
|
||||
|
||||
export type WebhookResponse = z.infer<typeof WebhookResponseSchema>;
|
||||
@@ -0,0 +1,12 @@
|
||||
import "./registry-augmentation.js";
|
||||
|
||||
export {
|
||||
type CaptureArgs,
|
||||
type CreateIntentArgs,
|
||||
type CreatePaymentServiceOptions,
|
||||
createPaymentService,
|
||||
type IntentByIdArgs,
|
||||
type OwnerScopedArgs,
|
||||
type PaymentService,
|
||||
type RefundArgs,
|
||||
} from "./payment-service.js";
|
||||
@@ -0,0 +1,76 @@
|
||||
# payment-service
|
||||
|
||||
## Purpose
|
||||
|
||||
Owner-scoped lifecycle service that wraps the caller-injected `PaymentProvider` with an ownership map (`PaymentIntentStore`) so every read/write checks the caller is the intent's owner before delegating. This is the surface the plugin's routes call into — they read `c.get("user").id`, pass it as `ownerId`, and the service handles the gate.
|
||||
|
||||
Foreign-intent access raises `PaymentPluginError(NOT_FOUND)` (HTTP 404). This is anti-enumeration by design: a caller asking for an intent that belongs to someone else gets the same response as a caller asking for an intent that doesn't exist, so they can't iterate `pi_1`, `pi_2`, `pi_3` to map other users' activity.
|
||||
|
||||
The service also emits the full OTel instrument set (`payments.intents_created_total`, `payments.captures_total`, `payments.refunds_total`, `payments.intent_duration_ms`) and translates provider errors into typed `PaymentPluginError`s with stable `pluginCode` discriminants so route handlers can branch on the cause without parsing error messages.
|
||||
|
||||
## Features
|
||||
|
||||
- Owner-scoped 7-method lifecycle: `createIntent`, `confirmIntent`, `captureIntent`, `cancelIntent`, `refund`, `getIntent`, `listIntents`.
|
||||
- Anti-enumeration 404 on foreign-intent access.
|
||||
- Currency mismatch + refund-exceeds-captured pre-checks before hitting the provider.
|
||||
- `PROVIDER_ERROR` wrapping for every unhandled provider throw (502 status).
|
||||
- `INTENT_CREATION_FAILED` guard against providers that resolve with no intent.
|
||||
- Injectable `Clock` for deterministic `createdAt` timestamps in tests.
|
||||
- Provider name exposed on `service.providerName` so callers can label dashboards.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createPaymentService } from "@suaveplan/api-plugin-payment/services/payment-service";
|
||||
import { MemoryPaymentIntentStore } from "@suaveplan/api-plugin-payment/store/memory-store";
|
||||
|
||||
const service = createPaymentService({
|
||||
provider: new StripeProvider({ apiKey: process.env.STRIPE_KEY! }),
|
||||
store: new MemoryPaymentIntentStore(),
|
||||
});
|
||||
const intent = await service.createIntent({
|
||||
ownerId: principal.id,
|
||||
input: { amount: { amount: 2500n, currency: "USD" } },
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
// In a route handler:
|
||||
app.post("/payments/intents", async (c) => {
|
||||
const principal = c.get("user");
|
||||
if (!principal) throw new Error("unreachable — requireAuth gates this");
|
||||
const body = CreatePaymentIntentBodySchema.parse(await c.req.json());
|
||||
try {
|
||||
const intent = await service.createIntent({
|
||||
ownerId: principal.id,
|
||||
tenantId: principal.tenantId,
|
||||
input: body,
|
||||
});
|
||||
return c.json({ intent }, 201);
|
||||
} catch (err) {
|
||||
if (err instanceof PaymentPluginError && err.code === PAYMENT_PLUGIN_CODES.CURRENCY_MISMATCH) {
|
||||
return c.json({ error: err.code }, 422);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createPaymentService({ provider, store, clock? }): PaymentService`.
|
||||
- `PaymentService` — 7-method interface: `createIntent({ownerId, tenantId?, input})`, `confirmIntent({id, ownerId, tenantId?})`, `captureIntent({id, ownerId, amount?})`, `cancelIntent({id, ownerId})`, `refund({ownerId, input})`, `getIntent({id, ownerId})`, `listIntents({ownerId, limit?})`.
|
||||
- `providerName: string` — convenience accessor.
|
||||
- Throws `PaymentPluginError(NOT_FOUND)` on foreign/missing intents.
|
||||
- Throws `PaymentPluginError(CURRENCY_MISMATCH)` when capture/refund currency disagrees with the intent.
|
||||
- Throws `PaymentPluginError(REFUND_EXCEEDS_CAPTURED)` when refund amount > (capturedAmount − refundedAmount).
|
||||
- Throws `PaymentPluginError(PROVIDER_ERROR)` on any unhandled provider throw.
|
||||
- Throws `PaymentPluginError(INTENT_CREATION_FAILED)` when provider returns no intent.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`listIntents` iterates the store's owner-filtered rows and re-fetches each intent from the provider so the response always reflects up-to-date provider-side status (a webhook may have arrived between create and list). The N+1 cost is acceptable at the default page-size of 20; persistent-store implementations can later layer a denormalised cache to avoid the round-trip without changing the surface.
|
||||
|
||||
The `assertCurrencyMatches` guard runs AFTER the capture in the `captureIntent` path because the provider is authoritative on the intent's actual currency (operators occasionally migrate intents between currencies via off-band APIs). The pre-fetch in the refund path uses the same canonical `getIntent` call so the currency check happens against the truly-current state, not an out-of-date local cache.
|
||||
@@ -0,0 +1,559 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { FakePaymentProvider } from "../../tests/setup/fake-provider.js";
|
||||
import {
|
||||
PAYMENT_PLUGIN_CODES,
|
||||
type PaymentPluginError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { resetPaymentInstruments } from "../otel/instruments.js";
|
||||
import { MemoryPaymentIntentStore } from "../store/memory-store.js";
|
||||
import { createPaymentService } from "./payment-service.js";
|
||||
|
||||
function makeService() {
|
||||
const provider = new FakePaymentProvider();
|
||||
const store = new MemoryPaymentIntentStore();
|
||||
const service = createPaymentService({ provider, store });
|
||||
return { provider, store, service };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetPaymentInstruments();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// nothing to flush — each test uses a fresh service
|
||||
});
|
||||
|
||||
describe("PaymentService — owner-scoped lifecycle", () => {
|
||||
it("createIntent records owner in store and returns the intent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service, store } = makeService();
|
||||
const intent = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 1000n, currency: "USD" } },
|
||||
});
|
||||
expect(intent.id).toBe("pi_1");
|
||||
const stored = await store.get(intent.id);
|
||||
expect(stored?.ownerId).toBe("u1");
|
||||
});
|
||||
|
||||
it("createIntent records tenantId when supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service, store } = makeService();
|
||||
const intent = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
tenantId: "t1",
|
||||
input: { amount: { amount: 100n, currency: "USD" } },
|
||||
});
|
||||
const stored = await store.get(intent.id);
|
||||
expect(stored?.tenantId).toBe("t1");
|
||||
});
|
||||
|
||||
it("createIntent wraps provider failures as PROVIDER_ERROR", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service, provider } = makeService();
|
||||
provider.failCreate = true;
|
||||
try {
|
||||
await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 1n, currency: "USD" } },
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as PaymentPluginError).pluginCode).toBe(
|
||||
PAYMENT_PLUGIN_CODES.PROVIDER_ERROR
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("createIntent rejects an empty provider response with INTENT_CREATION_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service, provider } = makeService();
|
||||
provider.returnEmptyOnCreate = true;
|
||||
try {
|
||||
await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 1n, currency: "USD" } },
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as PaymentPluginError).pluginCode).toBe(
|
||||
PAYMENT_PLUGIN_CODES.INTENT_CREATION_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("getIntent returns the provider intent for the owner", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service } = makeService();
|
||||
const created = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 100n, currency: "USD" } },
|
||||
});
|
||||
const fetched = await service.getIntent({
|
||||
id: created.id,
|
||||
ownerId: "u1",
|
||||
});
|
||||
expect(fetched.id).toBe(created.id);
|
||||
});
|
||||
|
||||
it("getIntent for foreign owner returns NOT_FOUND", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service } = makeService();
|
||||
const created = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 100n, currency: "USD" } },
|
||||
});
|
||||
try {
|
||||
await service.getIntent({ id: created.id, ownerId: "u2" });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as PaymentPluginError).pluginCode).toBe(
|
||||
PAYMENT_PLUGIN_CODES.NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("getIntent for missing intent returns NOT_FOUND", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service } = makeService();
|
||||
try {
|
||||
await service.getIntent({ id: "pi_unknown", ownerId: "u1" });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as PaymentPluginError).pluginCode).toBe(
|
||||
PAYMENT_PLUGIN_CODES.NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("confirmIntent moves status forward", { timeout: 5000 }, async () => {
|
||||
const { service } = makeService();
|
||||
const created = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: {
|
||||
amount: { amount: 100n, currency: "USD" },
|
||||
captureMethod: "manual",
|
||||
},
|
||||
});
|
||||
const confirmed = await service.confirmIntent({
|
||||
id: created.id,
|
||||
ownerId: "u1",
|
||||
});
|
||||
expect(confirmed.status).toBe("authorized");
|
||||
});
|
||||
|
||||
it("captureIntent applies a partial amount", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service } = makeService();
|
||||
const created = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 500n, currency: "USD" } },
|
||||
});
|
||||
const captured = await service.captureIntent({
|
||||
id: created.id,
|
||||
ownerId: "u1",
|
||||
amount: { amount: 200n, currency: "USD" },
|
||||
});
|
||||
expect(captured.capturedAmount.amount).toBe(200n);
|
||||
});
|
||||
|
||||
it("captureIntent without amount captures full intent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service } = makeService();
|
||||
const created = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 500n, currency: "USD" } },
|
||||
});
|
||||
const captured = await service.captureIntent({
|
||||
id: created.id,
|
||||
ownerId: "u1",
|
||||
});
|
||||
expect(captured.capturedAmount.amount).toBe(500n);
|
||||
});
|
||||
|
||||
it("captureIntent rejects currency mismatch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service } = makeService();
|
||||
const created = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 500n, currency: "USD" } },
|
||||
});
|
||||
try {
|
||||
await service.captureIntent({
|
||||
id: created.id,
|
||||
ownerId: "u1",
|
||||
amount: { amount: 200n, currency: "EUR" },
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as PaymentPluginError).pluginCode).toBe(
|
||||
PAYMENT_PLUGIN_CODES.CURRENCY_MISMATCH
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("captureIntent wraps unexpected provider errors", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service, provider } = makeService();
|
||||
const created = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 500n, currency: "USD" } },
|
||||
});
|
||||
provider.captureIntent = async () => {
|
||||
throw new Error("upstream offline");
|
||||
};
|
||||
try {
|
||||
await service.captureIntent({
|
||||
id: created.id,
|
||||
ownerId: "u1",
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as PaymentPluginError).pluginCode).toBe(
|
||||
PAYMENT_PLUGIN_CODES.PROVIDER_ERROR
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("cancelIntent updates status", { timeout: 5000 }, async () => {
|
||||
const { service } = makeService();
|
||||
const created = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 1n, currency: "USD" } },
|
||||
});
|
||||
const canceled = await service.cancelIntent({
|
||||
id: created.id,
|
||||
ownerId: "u1",
|
||||
});
|
||||
expect(canceled.status).toBe("canceled");
|
||||
});
|
||||
|
||||
it("cancelIntent wraps provider errors", { timeout: 5000 }, async () => {
|
||||
const { service, provider } = makeService();
|
||||
const created = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 1n, currency: "USD" } },
|
||||
});
|
||||
provider.cancelIntent = async () => {
|
||||
throw new Error("offline");
|
||||
};
|
||||
try {
|
||||
await service.cancelIntent({
|
||||
id: created.id,
|
||||
ownerId: "u1",
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as PaymentPluginError).pluginCode).toBe(
|
||||
PAYMENT_PLUGIN_CODES.PROVIDER_ERROR
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("confirmIntent wraps provider errors", { timeout: 5000 }, async () => {
|
||||
const { service, provider } = makeService();
|
||||
const created = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: {
|
||||
amount: { amount: 1n, currency: "USD" },
|
||||
captureMethod: "manual",
|
||||
},
|
||||
});
|
||||
provider.confirmIntent = async () => {
|
||||
throw new Error("offline");
|
||||
};
|
||||
try {
|
||||
await service.confirmIntent({
|
||||
id: created.id,
|
||||
ownerId: "u1",
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as PaymentPluginError).pluginCode).toBe(
|
||||
PAYMENT_PLUGIN_CODES.PROVIDER_ERROR
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("getIntent wraps provider errors", { timeout: 5000 }, async () => {
|
||||
const { service, provider } = makeService();
|
||||
const created = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 1n, currency: "USD" } },
|
||||
});
|
||||
provider.getIntent = async () => {
|
||||
throw new Error("offline");
|
||||
};
|
||||
try {
|
||||
await service.getIntent({
|
||||
id: created.id,
|
||||
ownerId: "u1",
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as PaymentPluginError).pluginCode).toBe(
|
||||
PAYMENT_PLUGIN_CODES.PROVIDER_ERROR
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("refund issues a partial refund and updates state", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service } = makeService();
|
||||
const created = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 1000n, currency: "USD" } },
|
||||
});
|
||||
await service.captureIntent({ id: created.id, ownerId: "u1" });
|
||||
const refund = await service.refund({
|
||||
ownerId: "u1",
|
||||
input: {
|
||||
paymentIntentId: created.id,
|
||||
amount: { amount: 400n, currency: "USD" },
|
||||
reason: "requested_by_customer",
|
||||
},
|
||||
});
|
||||
expect(refund.amount.amount).toBe(400n);
|
||||
});
|
||||
|
||||
it("refund rejects currency mismatch", { timeout: 5000 }, async () => {
|
||||
const { service } = makeService();
|
||||
const created = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 1000n, currency: "USD" } },
|
||||
});
|
||||
await service.captureIntent({ id: created.id, ownerId: "u1" });
|
||||
try {
|
||||
await service.refund({
|
||||
ownerId: "u1",
|
||||
input: {
|
||||
paymentIntentId: created.id,
|
||||
amount: { amount: 400n, currency: "EUR" },
|
||||
},
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as PaymentPluginError).pluginCode).toBe(
|
||||
PAYMENT_PLUGIN_CODES.CURRENCY_MISMATCH
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("refund rejects amounts exceeding captured-minus-refunded", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service } = makeService();
|
||||
const created = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 100n, currency: "USD" } },
|
||||
});
|
||||
await service.captureIntent({ id: created.id, ownerId: "u1" });
|
||||
try {
|
||||
await service.refund({
|
||||
ownerId: "u1",
|
||||
input: {
|
||||
paymentIntentId: created.id,
|
||||
amount: { amount: 5000n, currency: "USD" },
|
||||
},
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as PaymentPluginError).pluginCode).toBe(
|
||||
PAYMENT_PLUGIN_CODES.REFUND_EXCEEDS_CAPTURED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("refund rejects foreign owner with NOT_FOUND", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service } = makeService();
|
||||
const created = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 100n, currency: "USD" } },
|
||||
});
|
||||
await service.captureIntent({ id: created.id, ownerId: "u1" });
|
||||
try {
|
||||
await service.refund({
|
||||
ownerId: "u2",
|
||||
input: { paymentIntentId: created.id },
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as PaymentPluginError).pluginCode).toBe(
|
||||
PAYMENT_PLUGIN_CODES.NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("refund propagates PROVIDER_ERROR when provider refund fails", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service, provider } = makeService();
|
||||
const created = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 100n, currency: "USD" } },
|
||||
});
|
||||
await service.captureIntent({ id: created.id, ownerId: "u1" });
|
||||
provider.refund = async () => {
|
||||
throw new Error("offline");
|
||||
};
|
||||
try {
|
||||
await service.refund({
|
||||
ownerId: "u1",
|
||||
input: { paymentIntentId: created.id },
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as PaymentPluginError).pluginCode).toBe(
|
||||
PAYMENT_PLUGIN_CODES.PROVIDER_ERROR
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("refund propagates PROVIDER_ERROR when pre-check getIntent fails", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service, provider } = makeService();
|
||||
const created = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 100n, currency: "USD" } },
|
||||
});
|
||||
await service.captureIntent({ id: created.id, ownerId: "u1" });
|
||||
provider.getIntent = async () => {
|
||||
throw new Error("offline");
|
||||
};
|
||||
try {
|
||||
await service.refund({
|
||||
ownerId: "u1",
|
||||
input: { paymentIntentId: created.id },
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as PaymentPluginError).pluginCode).toBe(
|
||||
PAYMENT_PLUGIN_CODES.PROVIDER_ERROR
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("listIntents returns only owner-owned intents, limited", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service } = makeService();
|
||||
await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 1n, currency: "USD" } },
|
||||
});
|
||||
await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 2n, currency: "USD" } },
|
||||
});
|
||||
await service.createIntent({
|
||||
ownerId: "u2",
|
||||
input: { amount: { amount: 3n, currency: "USD" } },
|
||||
});
|
||||
const list = await service.listIntents({ ownerId: "u1" });
|
||||
expect(list).toHaveLength(2);
|
||||
const limited = await service.listIntents({ ownerId: "u1", limit: 1 });
|
||||
expect(limited).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("listIntents wraps provider errors", { timeout: 5000 }, async () => {
|
||||
const { service, provider } = makeService();
|
||||
await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 1n, currency: "USD" } },
|
||||
});
|
||||
provider.getIntent = async () => {
|
||||
throw new Error("offline");
|
||||
};
|
||||
try {
|
||||
await service.listIntents({ ownerId: "u1" });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as PaymentPluginError).pluginCode).toBe(
|
||||
PAYMENT_PLUGIN_CODES.PROVIDER_ERROR
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("wrapProviderError stringifies non-Error throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service, provider } = makeService();
|
||||
provider.getIntent = async () => {
|
||||
const v: unknown = "string-failure";
|
||||
throw v;
|
||||
};
|
||||
const created = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 1n, currency: "USD" } },
|
||||
});
|
||||
try {
|
||||
await service.getIntent({ id: created.id, ownerId: "u1" });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as PaymentPluginError).pluginCode).toBe(
|
||||
PAYMENT_PLUGIN_CODES.PROVIDER_ERROR
|
||||
);
|
||||
expect((err as Error).message).toContain("string-failure");
|
||||
}
|
||||
});
|
||||
|
||||
it("refund propagates tenantId through the ownership scope", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service, store } = makeService();
|
||||
const created = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
tenantId: "t1",
|
||||
input: { amount: { amount: 100n, currency: "USD" } },
|
||||
});
|
||||
await service.captureIntent({ id: created.id, ownerId: "u1" });
|
||||
await service.refund({
|
||||
ownerId: "u1",
|
||||
tenantId: "t1",
|
||||
input: { paymentIntentId: created.id },
|
||||
});
|
||||
const stored = await store.get(created.id);
|
||||
expect(stored?.tenantId).toBe("t1");
|
||||
});
|
||||
|
||||
it("exposes provider name", { timeout: 5000 }, () => {
|
||||
const { service } = makeService();
|
||||
expect(service.providerName).toBe("fake");
|
||||
});
|
||||
|
||||
it("createIntent + assertOwned uses injected clock", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new FakePaymentProvider();
|
||||
const store = new MemoryPaymentIntentStore();
|
||||
const clock = () => 42;
|
||||
const service = createPaymentService({ provider, store, clock });
|
||||
const created = await service.createIntent({
|
||||
ownerId: "u1",
|
||||
input: { amount: { amount: 1n, currency: "USD" } },
|
||||
});
|
||||
const owned = await store.get(created.id);
|
||||
expect(owned?.createdAt).toBe(42);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,337 @@
|
||||
import type {
|
||||
CreatePaymentIntentInput,
|
||||
PaymentIntent,
|
||||
PaymentProvider,
|
||||
Refund,
|
||||
RefundInput,
|
||||
} from "@suaveplan/payment";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import {
|
||||
PAYMENT_PLUGIN_CODES,
|
||||
PaymentPluginError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { getPaymentInstruments } from "../otel/instruments.js";
|
||||
import type {
|
||||
OwnedIntent,
|
||||
PaymentIntentStore,
|
||||
} from "../store/payment-intent-store.js";
|
||||
|
||||
/** Inputs to {@link createPaymentService}. */
|
||||
export interface CreatePaymentServiceOptions {
|
||||
readonly provider: PaymentProvider;
|
||||
readonly store: PaymentIntentStore;
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
/** Owner-scoped lifecycle args. */
|
||||
export interface OwnerScopedArgs {
|
||||
readonly ownerId: string;
|
||||
readonly tenantId?: string;
|
||||
}
|
||||
|
||||
export interface CreateIntentArgs extends OwnerScopedArgs {
|
||||
readonly input: CreatePaymentIntentInput;
|
||||
}
|
||||
export interface IntentByIdArgs extends OwnerScopedArgs {
|
||||
readonly id: string;
|
||||
}
|
||||
export interface CaptureArgs extends IntentByIdArgs {
|
||||
readonly amount?: CreatePaymentIntentInput["amount"];
|
||||
}
|
||||
export interface RefundArgs extends OwnerScopedArgs {
|
||||
readonly input: RefundInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-scoped payment lifecycle service. Wraps the caller-supplied
|
||||
* {@link PaymentProvider} with the ownership map ({@link
|
||||
* PaymentIntentStore}) so every read/write checks the caller is the
|
||||
* intent's owner before delegating. Foreign access yields
|
||||
* `PaymentPluginError(NOT_FOUND)` (HTTP 404) — anti-enumeration so a
|
||||
* caller can't distinguish "intent exists but belongs to someone else"
|
||||
* from "no such intent".
|
||||
*/
|
||||
export interface PaymentService {
|
||||
readonly providerName: string;
|
||||
createIntent(args: CreateIntentArgs): Promise<PaymentIntent>;
|
||||
confirmIntent(args: IntentByIdArgs): Promise<PaymentIntent>;
|
||||
captureIntent(args: CaptureArgs): Promise<PaymentIntent>;
|
||||
cancelIntent(args: IntentByIdArgs): Promise<PaymentIntent>;
|
||||
refund(args: RefundArgs): Promise<Refund>;
|
||||
getIntent(args: IntentByIdArgs): Promise<PaymentIntent>;
|
||||
listIntents(
|
||||
args: OwnerScopedArgs & { readonly limit?: number }
|
||||
): Promise<readonly PaymentIntent[]>;
|
||||
}
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
/**
|
||||
* Construct the canonical `PaymentService` impl backed by the supplied
|
||||
* provider + ownership store.
|
||||
*/
|
||||
export function createPaymentService(
|
||||
options: CreatePaymentServiceOptions
|
||||
): PaymentService {
|
||||
const { provider, store } = options;
|
||||
const clock = options.clock ?? defaultClock;
|
||||
const providerName = provider.name;
|
||||
|
||||
async function assertOwned(args: IntentByIdArgs): Promise<OwnedIntent> {
|
||||
const owned = await store.get(args.id);
|
||||
if (owned === undefined || owned.ownerId !== args.ownerId) {
|
||||
throw new PaymentPluginError(
|
||||
PAYMENT_PLUGIN_CODES.NOT_FOUND,
|
||||
`Payment intent "${args.id}" not found`,
|
||||
{
|
||||
context: {
|
||||
statusCode: 404,
|
||||
intentId: args.id,
|
||||
ownerId: args.ownerId,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
return owned;
|
||||
}
|
||||
|
||||
function wrapProviderError(
|
||||
scope:
|
||||
| "createIntent"
|
||||
| "confirmIntent"
|
||||
| "captureIntent"
|
||||
| "cancelIntent"
|
||||
| "refund"
|
||||
| "getIntent",
|
||||
err: unknown
|
||||
): PaymentPluginError {
|
||||
if (err instanceof PaymentPluginError) return err;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return new PaymentPluginError(
|
||||
PAYMENT_PLUGIN_CODES.PROVIDER_ERROR,
|
||||
`Provider ${providerName} failed during ${scope}: ${message}`,
|
||||
{ context: { statusCode: 502 }, cause: err }
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
providerName,
|
||||
|
||||
async createIntent({ input, ownerId, tenantId }) {
|
||||
const instruments = getPaymentInstruments();
|
||||
const started = clock();
|
||||
let outcome: "success" | "failure" = "success";
|
||||
let intent: PaymentIntent;
|
||||
try {
|
||||
intent = await provider.createIntent(input);
|
||||
if (
|
||||
intent === null ||
|
||||
typeof intent !== "object" ||
|
||||
typeof intent.id !== "string"
|
||||
) {
|
||||
throw new PaymentPluginError(
|
||||
PAYMENT_PLUGIN_CODES.INTENT_CREATION_FAILED,
|
||||
"Provider returned no intent",
|
||||
{ context: { statusCode: 502 } }
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
outcome = "failure";
|
||||
instruments.intentsCreatedTotal.add(1, {
|
||||
outcome,
|
||||
provider: providerName,
|
||||
});
|
||||
instruments.intentDurationMs.record(clock() - started, {
|
||||
provider: providerName,
|
||||
});
|
||||
throw wrapProviderError("createIntent", err);
|
||||
}
|
||||
const record: OwnedIntent = {
|
||||
id: intent.id,
|
||||
ownerId,
|
||||
...(tenantId !== undefined ? { tenantId } : {}),
|
||||
createdAt: clock(),
|
||||
};
|
||||
await store.put(record);
|
||||
instruments.intentsCreatedTotal.add(1, {
|
||||
outcome,
|
||||
provider: providerName,
|
||||
});
|
||||
instruments.intentDurationMs.record(clock() - started, {
|
||||
provider: providerName,
|
||||
});
|
||||
return intent;
|
||||
},
|
||||
|
||||
async confirmIntent(args) {
|
||||
await assertOwned(args);
|
||||
try {
|
||||
return await provider.confirmIntent(args.id);
|
||||
} catch (err) {
|
||||
throw wrapProviderError("confirmIntent", err);
|
||||
}
|
||||
},
|
||||
|
||||
async captureIntent(args) {
|
||||
const instruments = getPaymentInstruments();
|
||||
const current = await assertOwned(args);
|
||||
try {
|
||||
const intent = await (args.amount === undefined
|
||||
? provider.captureIntent(args.id)
|
||||
: provider.captureIntent(args.id, args.amount));
|
||||
assertCurrencyMatches(intent, args.amount, current.id);
|
||||
instruments.capturesTotal.add(1, {
|
||||
outcome: "success",
|
||||
provider: providerName,
|
||||
});
|
||||
return intent;
|
||||
} catch (err) {
|
||||
instruments.capturesTotal.add(1, {
|
||||
outcome: "failure",
|
||||
provider: providerName,
|
||||
});
|
||||
throw wrapProviderError("captureIntent", err);
|
||||
}
|
||||
},
|
||||
|
||||
async cancelIntent(args) {
|
||||
await assertOwned(args);
|
||||
try {
|
||||
return await provider.cancelIntent(args.id);
|
||||
} catch (err) {
|
||||
throw wrapProviderError("cancelIntent", err);
|
||||
}
|
||||
},
|
||||
|
||||
async refund({ input, ownerId, tenantId }) {
|
||||
const instruments = getPaymentInstruments();
|
||||
const owned = await assertOwned({
|
||||
id: input.paymentIntentId,
|
||||
ownerId,
|
||||
...(tenantId !== undefined ? { tenantId } : {}),
|
||||
});
|
||||
const baseIntent = await safelyFetchIntent(provider, owned.id);
|
||||
if (
|
||||
input.amount !== undefined &&
|
||||
input.amount.currency !== baseIntent.amount.currency
|
||||
) {
|
||||
instruments.refundsTotal.add(1, {
|
||||
outcome: "failure",
|
||||
provider: providerName,
|
||||
});
|
||||
throw new PaymentPluginError(
|
||||
PAYMENT_PLUGIN_CODES.CURRENCY_MISMATCH,
|
||||
`Refund currency ${input.amount.currency} disagrees with intent currency ${baseIntent.amount.currency}`,
|
||||
{
|
||||
context: {
|
||||
statusCode: 422,
|
||||
intentId: owned.id,
|
||||
currency: input.amount.currency,
|
||||
expectedCurrency: baseIntent.amount.currency,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
if (
|
||||
input.amount !== undefined &&
|
||||
exceedsRefundable(baseIntent, input.amount.amount)
|
||||
) {
|
||||
instruments.refundsTotal.add(1, {
|
||||
outcome: "failure",
|
||||
provider: providerName,
|
||||
});
|
||||
throw new PaymentPluginError(
|
||||
PAYMENT_PLUGIN_CODES.REFUND_EXCEEDS_CAPTURED,
|
||||
`Refund amount exceeds captured-minus-refunded for intent "${owned.id}"`,
|
||||
{
|
||||
context: {
|
||||
statusCode: 422,
|
||||
intentId: owned.id,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
try {
|
||||
const refund = await provider.refund(input);
|
||||
instruments.refundsTotal.add(1, {
|
||||
outcome: "success",
|
||||
provider: providerName,
|
||||
});
|
||||
return refund;
|
||||
} catch (err) {
|
||||
instruments.refundsTotal.add(1, {
|
||||
outcome: "failure",
|
||||
provider: providerName,
|
||||
});
|
||||
throw wrapProviderError("refund", err);
|
||||
}
|
||||
},
|
||||
|
||||
async getIntent(args) {
|
||||
await assertOwned(args);
|
||||
try {
|
||||
return await provider.getIntent(args.id);
|
||||
} catch (err) {
|
||||
throw wrapProviderError("getIntent", err);
|
||||
}
|
||||
},
|
||||
|
||||
async listIntents(args) {
|
||||
const ownedRows = await store.listByOwner(args.ownerId);
|
||||
const limit = args.limit ?? 20;
|
||||
const slice = ownedRows.slice(0, limit);
|
||||
const intents: PaymentIntent[] = [];
|
||||
for (const row of slice) {
|
||||
try {
|
||||
intents.push(await provider.getIntent(row.id));
|
||||
} catch (err) {
|
||||
throw wrapProviderError("getIntent", err);
|
||||
}
|
||||
}
|
||||
return intents;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function safelyFetchIntent(
|
||||
provider: PaymentProvider,
|
||||
id: string
|
||||
): Promise<PaymentIntent> {
|
||||
try {
|
||||
return await provider.getIntent(id);
|
||||
} catch (err) {
|
||||
throw new PaymentPluginError(
|
||||
PAYMENT_PLUGIN_CODES.PROVIDER_ERROR,
|
||||
`Provider ${provider.name} failed during getIntent (refund pre-check)`,
|
||||
{ context: { statusCode: 502 }, cause: err }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertCurrencyMatches(
|
||||
intent: PaymentIntent,
|
||||
requested: CreatePaymentIntentInput["amount"] | undefined,
|
||||
intentId: string
|
||||
): void {
|
||||
if (requested === undefined) return;
|
||||
if (requested.currency !== intent.amount.currency) {
|
||||
throw new PaymentPluginError(
|
||||
PAYMENT_PLUGIN_CODES.CURRENCY_MISMATCH,
|
||||
`Capture currency ${requested.currency} disagrees with intent currency ${intent.amount.currency}`,
|
||||
{
|
||||
context: {
|
||||
statusCode: 422,
|
||||
intentId,
|
||||
currency: requested.currency,
|
||||
expectedCurrency: intent.amount.currency,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function exceedsRefundable(intent: PaymentIntent, requested: bigint): boolean {
|
||||
const captured = intent.capturedAmount.amount;
|
||||
const refunded = intent.refundedAmount.amount;
|
||||
return requested > captured - refunded;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# registry-augmentation
|
||||
|
||||
## Purpose
|
||||
|
||||
TypeScript module augmentation that adds the `paymentService` slot to `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` interface. Without this declaration, `ctx.getService("paymentService")` types as `never` because the base `ServiceRegistry` is empty and only consumer-declared augmentations populate it. Importing this module (which the package root barrel does for its side effect) ensures every downstream plugin that depends on `@suaveplan/api-plugin-payment` sees the typed slot.
|
||||
|
||||
The file also redeclares the host-side slots (`app`, `openAPIRegistry`, `logger`, `eventBus`, `clock`) so the payment plugin's `onInit` resolves them with full typing even when the consumer has not transitively pulled in `@suaveplan/api`'s own registry augmentation. TypeScript merges duplicate declarations of the same interface, so coexistence with `@suaveplan/api`'s file is safe.
|
||||
|
||||
## Features
|
||||
|
||||
- Adds `paymentService: PaymentService` to `ServiceRegistry`.
|
||||
- Redeclares the 5 host slots so isolated unit-tests don't have to import `@suaveplan/api`.
|
||||
- Pure type-level — zero runtime cost.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
// Side-effect import — augments the global ServiceRegistry
|
||||
import "@suaveplan/api-plugin-payment/services/payment-service";
|
||||
|
||||
const service = ctx.getService("paymentService"); // typed PaymentService | undefined
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
// In a downstream plugin:
|
||||
import type { PaymentService } from "@suaveplan/api-plugin-payment/services/payment-service";
|
||||
|
||||
declare module "@suaveplan/api-plugin/service-registry" {
|
||||
interface ServiceRegistry {
|
||||
subscriptionEngine: SubscriptionEngine;
|
||||
}
|
||||
}
|
||||
|
||||
// Consumes both this plugin's slot and its own:
|
||||
const payment = ctx.getService("paymentService");
|
||||
const subs = ctx.getService("subscriptionEngine");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
This module has no runtime exports — it contributes the `paymentService` slot via TypeScript module augmentation.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The host-slot redeclaration is intentional duplication. TypeScript merges duplicate interface declarations, so having both `@suaveplan/api` and `@suaveplan/api-plugin-payment` declare `app: OpenAPIHono` is type-safe. The benefit is that the payment plugin's tests can construct a `PluginContext` directly (without booting a full `@suaveplan/api` instance) and still type-check the `ctx.getService("app")` call sites — a small ergonomic win that avoids a circular dev-dependency on the orchestrator package.
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* ServiceRegistry module augmentation — declares the `paymentService`
|
||||
* slot on `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry`
|
||||
* interface so downstream plugins typed against the augmented registry
|
||||
* see `ctx.getService("paymentService")` resolve to `PaymentService |
|
||||
* undefined` without any cast.
|
||||
*
|
||||
* The host-side slots (`app`, `openAPIRegistry`, `logger`, `eventBus`,
|
||||
* `clock`) are owned by `@suaveplan/api`'s own registry-augmentation
|
||||
* module. We re-declare them here so the payment plugin's `onInit(ctx)`
|
||||
* can resolve them with full typing even when the consumer has not
|
||||
* explicitly imported `@suaveplan/api` (e.g. during isolated unit tests
|
||||
* of the plugin). TypeScript merges duplicate declarations, so this is
|
||||
* safe alongside `@suaveplan/api`'s declarations.
|
||||
*/
|
||||
import type { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import type { OpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import type { Clock, EventBusLike, LoggerLike } from "@suaveplan/types";
|
||||
import type { PaymentService } from "./payment-service.js";
|
||||
|
||||
declare module "@suaveplan/api-plugin/service-registry" {
|
||||
interface ServiceRegistry {
|
||||
paymentService: PaymentService;
|
||||
app: OpenAPIHono;
|
||||
openAPIRegistry: OpenAPIRegistry;
|
||||
logger: LoggerLike;
|
||||
eventBus: EventBusLike;
|
||||
clock: Clock;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { MemoryPaymentIntentStore } from "./memory-store.js";
|
||||
export type {
|
||||
OwnedIntent,
|
||||
PaymentIntentStore,
|
||||
} from "./payment-intent-store.js";
|
||||
@@ -0,0 +1,52 @@
|
||||
# memory-store
|
||||
|
||||
## Purpose
|
||||
|
||||
In-process default implementation of the `PaymentIntentStore` contract. Stores owner records in a `Map<string, OwnedIntent>` so reads are O(1) and `listByOwner` is O(n) over the full row set. This is the plugin's default store when the caller does not supply one via `createPaymentPlugin({ store })`.
|
||||
|
||||
Operators wire a persistent store (SurrealDB, Postgres, Redis) in production by injecting an alternative implementation. The in-memory store remains the right choice for tests, ephemeral dev environments, and small single-process deployments where the intent ledger is the provider's responsibility.
|
||||
|
||||
## Features
|
||||
|
||||
- O(1) `put`/`get`; O(n) `listByOwner` with descending-by-`createdAt` ordering for stable pagination semantics.
|
||||
- Test-only `clear()` method makes between-test cleanup straightforward.
|
||||
- No external dependencies — pure in-process `Map` with no IO surface.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { MemoryPaymentIntentStore } from "@suaveplan/api-plugin-payment/store/memory-store";
|
||||
|
||||
const store = new MemoryPaymentIntentStore();
|
||||
await store.put({ id: "pi_1", ownerId: "u1", createdAt: Date.now() });
|
||||
const owned = await store.listByOwner("u1");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createPaymentPlugin } from "@suaveplan/api-plugin-payment";
|
||||
import { MemoryPaymentIntentStore } from "@suaveplan/api-plugin-payment/store/memory-store";
|
||||
|
||||
const store = new MemoryPaymentIntentStore();
|
||||
const plugin = createPaymentPlugin({
|
||||
provider: new FakePaymentProvider(),
|
||||
webhookSecret: "shhh-this-is-only-for-tests-and-is-long-enough",
|
||||
store, // hand the same store to tests so assertions can introspect it
|
||||
});
|
||||
|
||||
// in afterEach:
|
||||
store.clear();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `MemoryPaymentIntentStore implements PaymentIntentStore`.
|
||||
- `put(record: OwnedIntent): Promise<void>` — upsert by id.
|
||||
- `get(id: string): Promise<OwnedIntent | undefined>` — exact-id lookup.
|
||||
- `listByOwner(ownerId: string): Promise<readonly OwnedIntent[]>` — owner-filtered, descending by `createdAt`.
|
||||
- `clear(): void` — test-only; drops every record.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The store does not enforce uniqueness on `(ownerId, providerKey)` because provider responses are already idempotent at the API layer (Stripe et al. use `Idempotency-Key` for that contract). Sorting in `listByOwner` is descending so the newest intent surfaces first — every payments UI displays "most recent on top". The `clear()` method is intentionally synchronous because tests should not have to `await` cleanup.
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MemoryPaymentIntentStore } from "./memory-store.js";
|
||||
|
||||
function makeStore() {
|
||||
return new MemoryPaymentIntentStore();
|
||||
}
|
||||
|
||||
describe("MemoryPaymentIntentStore", () => {
|
||||
it("records and reads back a single owner row", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const s = makeStore();
|
||||
await s.put({ id: "pi_1", ownerId: "u1", createdAt: 1 });
|
||||
const found = await s.get("pi_1");
|
||||
expect(found?.ownerId).toBe("u1");
|
||||
});
|
||||
|
||||
it("returns undefined for missing ids", { timeout: 5000 }, async () => {
|
||||
const s = makeStore();
|
||||
expect(await s.get("missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("filters listByOwner to the requesting owner only", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const s = makeStore();
|
||||
await s.put({ id: "pi_a1", ownerId: "u1", createdAt: 1 });
|
||||
await s.put({ id: "pi_a2", ownerId: "u1", createdAt: 2 });
|
||||
await s.put({ id: "pi_b1", ownerId: "u2", createdAt: 3 });
|
||||
const owned = await s.listByOwner("u1");
|
||||
expect(owned.map((r) => r.id)).toEqual(["pi_a2", "pi_a1"]);
|
||||
});
|
||||
|
||||
it("sorts listByOwner descending by createdAt", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const s = makeStore();
|
||||
await s.put({ id: "pi_old", ownerId: "u1", createdAt: 1 });
|
||||
await s.put({ id: "pi_new", ownerId: "u1", createdAt: 10 });
|
||||
await s.put({ id: "pi_mid", ownerId: "u1", createdAt: 5 });
|
||||
const owned = await s.listByOwner("u1");
|
||||
expect(owned.map((r) => r.id)).toEqual(["pi_new", "pi_mid", "pi_old"]);
|
||||
});
|
||||
|
||||
it("returns an empty list when owner has no rows", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const s = makeStore();
|
||||
await s.put({ id: "pi_a", ownerId: "u1", createdAt: 1 });
|
||||
expect(await s.listByOwner("u_other")).toEqual([]);
|
||||
});
|
||||
|
||||
it("clear() drops every row", { timeout: 5000 }, async () => {
|
||||
const s = makeStore();
|
||||
await s.put({ id: "pi_a", ownerId: "u1", createdAt: 1 });
|
||||
s.clear();
|
||||
expect(await s.get("pi_a")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type {
|
||||
OwnedIntent,
|
||||
PaymentIntentStore,
|
||||
} from "./payment-intent-store.js";
|
||||
|
||||
/**
|
||||
* Default in-process `PaymentIntentStore`. Stores owner records in a
|
||||
* `Map<string, OwnedIntent>`, so lookups are O(1) and `listByOwner`
|
||||
* is O(n). Suitable for tests, ephemeral dev environments, and any
|
||||
* single-process deployment — operators wire a persistent store in
|
||||
* production via `createPaymentPlugin({ store: ... })`.
|
||||
*/
|
||||
export class MemoryPaymentIntentStore implements PaymentIntentStore {
|
||||
private readonly records = new Map<string, OwnedIntent>();
|
||||
|
||||
public async put(record: OwnedIntent): Promise<void> {
|
||||
this.records.set(record.id, record);
|
||||
}
|
||||
|
||||
public async get(id: string): Promise<OwnedIntent | undefined> {
|
||||
return this.records.get(id);
|
||||
}
|
||||
|
||||
public async listByOwner(ownerId: string): Promise<readonly OwnedIntent[]> {
|
||||
const result: OwnedIntent[] = [];
|
||||
for (const record of this.records.values()) {
|
||||
if (record.ownerId === ownerId) result.push(record);
|
||||
}
|
||||
result.sort((a, b) => b.createdAt - a.createdAt);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Test-only — clear all rows. */
|
||||
public clear(): void {
|
||||
this.records.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
# payment-intent-store
|
||||
|
||||
## Purpose
|
||||
|
||||
Persistence contract for the intent-id → owner map every payment plugin needs to enforce per-user scoping on lookups, lists, and mutations. Without this map, a sequential or guessable intent id (which is what most provider APIs return) would let any authenticated user enumerate or even mutate other users' intents — the textbook insecure-direct-object-reference defect.
|
||||
|
||||
The interface ships as an injectable dependency so operators can wire a SurrealDB, Postgres, Redis, or DynamoDB backing store without changing any code in the plugin. The default in-process `MemoryPaymentIntentStore` (in the sibling module) is provided for tests, ephemeral dev environments, and single-process deployments.
|
||||
|
||||
## Features
|
||||
|
||||
- Three-method contract: `put`, `get`, `listByOwner` — minimal surface so adapters stay small.
|
||||
- `OwnedIntent` carries `id`, `ownerId`, optional `tenantId`, and `createdAt` for sorting / GDPR retention.
|
||||
- Asynchronous everywhere so storage adapters can plug in network IO without monkey-patching the surface.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
type PaymentIntentStore,
|
||||
MemoryPaymentIntentStore,
|
||||
} from "@suaveplan/api-plugin-payment/store/payment-intent-store";
|
||||
|
||||
const store: PaymentIntentStore = new MemoryPaymentIntentStore();
|
||||
await store.put({ id: "pi_abc", ownerId: "u1", createdAt: Date.now() });
|
||||
const owned = await store.listByOwner("u1");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
class SurrealPaymentIntentStore implements PaymentIntentStore {
|
||||
public constructor(private readonly db: SurrealAdapter) {}
|
||||
public async put(record: OwnedIntent): Promise<void> {
|
||||
await this.db.create("payment_intent", record);
|
||||
}
|
||||
public async get(id: string): Promise<OwnedIntent | undefined> {
|
||||
const rows = await this.db.select<OwnedIntent>(`payment_intent:${id}`);
|
||||
return rows[0];
|
||||
}
|
||||
public async listByOwner(ownerId: string): Promise<readonly OwnedIntent[]> {
|
||||
return this.db.query<OwnedIntent[]>(
|
||||
"SELECT * FROM payment_intent WHERE ownerId = $o",
|
||||
{ o: ownerId }
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `OwnedIntent` — `{ id: string; ownerId: string; tenantId?: string; createdAt: number }`.
|
||||
- `PaymentIntentStore` — `put(record): Promise<void>`, `get(id): Promise<OwnedIntent | undefined>`, `listByOwner(ownerId): Promise<readonly OwnedIntent[]>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The interface intentionally does NOT include `delete` — payment intents are a regulated audit surface and removal is the operator's responsibility (GDPR erasure, retention policy enforcement) outside this plugin's scope. The `createdAt` field is `number` (Unix ms) rather than `Date` so the structure stays JSON-safe for stores that round-trip through external serialisers.
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Ownership record stored alongside each `PaymentIntent.id` returned by
|
||||
* the provider. The plugin uses this to scope every read/write to the
|
||||
* authenticated principal — without it, a sequential intent id would
|
||||
* leak across users (anti-enumeration).
|
||||
*/
|
||||
export interface OwnedIntent {
|
||||
/** Provider-issued intent id. */
|
||||
readonly id: string;
|
||||
/** Owner `Principal.id` — set when the intent was created. */
|
||||
readonly ownerId: string;
|
||||
/** Optional tenant scope when `@suaveplan/api-tenant` is mounted. */
|
||||
readonly tenantId?: string;
|
||||
/** Wall-clock ms when the row was recorded. */
|
||||
readonly createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistence contract for the intent-id → owner map. Implementations
|
||||
* are expected to be deterministic and side-effect-free beyond their
|
||||
* single storage call. The default `MemoryPaymentIntentStore` ships
|
||||
* with the plugin; operators inject a Surreal/Postgres/Redis-backed
|
||||
* variant in production.
|
||||
*/
|
||||
export interface PaymentIntentStore {
|
||||
/** Record a newly-created intent's owner. */
|
||||
put(record: OwnedIntent): Promise<void>;
|
||||
/** Look up an intent's owner record. */
|
||||
get(id: string): Promise<OwnedIntent | undefined>;
|
||||
/** Enumerate intents owned by a given principal. */
|
||||
listByOwner(ownerId: string): Promise<readonly OwnedIntent[]>;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type {
|
||||
PaymentPluginConfig,
|
||||
PaymentPluginOptions,
|
||||
} from "./types.js";
|
||||
56
packages/api-stack/api/api-plugin-payment/src/types/types.md
Normal file
56
packages/api-stack/api/api-plugin-payment/src/types/types.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# types
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Public type surface for `@suaveplan/api-plugin-payment`. Declares the two top-level interfaces every consumer sees: `PaymentPluginOptions` (what the caller passes to `createPaymentPlugin`) and `PaymentPluginConfig` (the resolved, defaults-applied shape used internally by the plugin's lifecycle hooks).
|
||||
|
||||
Keeping these in `src/types/` rather than scattered across implementation files lets downstream consumers depend on the contract via `@suaveplan/api-plugin-payment/types/types` without importing the runtime factory transitively.
|
||||
|
||||
## Features
|
||||
|
||||
- `PaymentPluginOptions` — caller-facing factory input. `provider` and `webhookSecret` are required; everything else has a runtime default.
|
||||
- `PaymentPluginConfig` — fully-resolved runtime shape after Zod parse. Used as the `TConfig` parameter of `definePlugin<PaymentPluginConfig>(...)`.
|
||||
- All references to other module types (`PaymentIntentStore`, `WebhookAlgorithmName`) come through named imports so consumers can swap their implementations behind the interface.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { PaymentPluginOptions } from "@suaveplan/api-plugin-payment";
|
||||
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
|
||||
|
||||
const opts: PaymentPluginOptions = {
|
||||
provider: new StripeProvider({ apiKey: process.env.STRIPE_KEY! }),
|
||||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { PaymentPluginOptions } from "@suaveplan/api-plugin-payment";
|
||||
import { MemoryPaymentIntentStore } from "@suaveplan/api-plugin-payment/store/memory-store";
|
||||
import { CustomProvider } from "./my-provider.js";
|
||||
|
||||
const opts: PaymentPluginOptions = {
|
||||
provider: new CustomProvider(),
|
||||
webhookSecret: "secret-at-least-32-chars-long-please",
|
||||
routePrefix: "/billing",
|
||||
webhookToleranceMs: 60_000,
|
||||
webhookAlgorithm: "SHA-512",
|
||||
store: new MemoryPaymentIntentStore(),
|
||||
logger: console,
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PaymentPluginOptions` — required: `provider: PaymentProvider`, `webhookSecret: string`. Optional: `routePrefix` (default `/payments`), `store` (default in-memory), `webhookToleranceMs` (default `300_000`), `webhookAlgorithm` (default `"SHA-256"`), `logger`, `clock`.
|
||||
- `PaymentPluginConfig` — every field above with defaults applied. The plugin's lifecycle hooks accept this resolved shape.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`PaymentPluginConfig` carries `provider` as the same `PaymentProvider` reference the caller passed — the Zod schema validates the surrounding wrapper but treats `provider` as a structural opaque value (`z.custom`) since `PaymentProvider` is a runtime interface, not a serialisable shape. This matches `@suaveplan/api-plugin-auth`'s treatment of `betterAuth` (validated structurally, not deeply).
|
||||
65
packages/api-stack/api/api-plugin-payment/src/types/types.ts
Normal file
65
packages/api-stack/api/api-plugin-payment/src/types/types.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { PaymentProvider } from "@suaveplan/payment";
|
||||
import type { Clock, LoggerLike } from "@suaveplan/types";
|
||||
import type { PaymentIntentStore } from "../store/payment-intent-store.js";
|
||||
import type { WebhookAlgorithmName } from "../webhook/verify-webhook.js";
|
||||
|
||||
/**
|
||||
* Top-level options accepted by `createPaymentPlugin`. Validated by
|
||||
* `PaymentPluginOptionsSchema` at the factory boundary; the resolved
|
||||
* shape (with defaults applied) is the runtime `PaymentPluginConfig`.
|
||||
*/
|
||||
export interface PaymentPluginOptions {
|
||||
/**
|
||||
* Caller-injected concrete `PaymentProvider`. The plugin does not
|
||||
* pick a default — the operator constructs (e.g.)
|
||||
* `new StripeProvider({ apiKey })` and passes it here.
|
||||
*/
|
||||
readonly provider: PaymentProvider;
|
||||
/**
|
||||
* URL prefix where the plugin mounts its 6 routes + webhook receiver.
|
||||
* Defaults to `/payments`. MUST start with `/` and MUST NOT end with
|
||||
* `/` (`/` alone is also accepted).
|
||||
*/
|
||||
readonly routePrefix?: string;
|
||||
/**
|
||||
* Pluggable `PaymentIntentStore` mapping provider-side intent ids
|
||||
* back to owner ids. Defaults to a fresh in-process
|
||||
* `MemoryPaymentIntentStore`.
|
||||
*/
|
||||
readonly store?: PaymentIntentStore;
|
||||
/**
|
||||
* Shared secret used by `WebhookVerifier` to authenticate inbound
|
||||
* webhook calls from the provider.
|
||||
*/
|
||||
readonly webhookSecret: string;
|
||||
/**
|
||||
* Maximum acceptable webhook age in ms. Defaults to `300_000`
|
||||
* (5 minutes) — matches Stripe's recommendation.
|
||||
*/
|
||||
readonly webhookToleranceMs?: number;
|
||||
/**
|
||||
* HMAC algorithm used by the webhook signature. Defaults to
|
||||
* `"SHA-256"`.
|
||||
*/
|
||||
readonly webhookAlgorithm?: WebhookAlgorithmName;
|
||||
/** Optional logger override. Defaults to `ctx.logger`. */
|
||||
readonly logger?: LoggerLike;
|
||||
/** Optional clock override; defaults to `Date.now`-backed wall clock. */
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully-resolved options after Zod parse — every default has been
|
||||
* applied. The plugin's `PluginDescriptor.configSchema` infers to this
|
||||
* type.
|
||||
*/
|
||||
export interface PaymentPluginConfig {
|
||||
readonly provider: PaymentProvider;
|
||||
readonly routePrefix: string;
|
||||
readonly store?: PaymentIntentStore;
|
||||
readonly webhookSecret: string;
|
||||
readonly webhookToleranceMs: number;
|
||||
readonly webhookAlgorithm: WebhookAlgorithmName;
|
||||
readonly logger?: LoggerLike;
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
signPaymentWebhook,
|
||||
type VerifyPaymentWebhookOptions,
|
||||
verifyPaymentWebhook,
|
||||
type WebhookAlgorithmName,
|
||||
} from "./verify-webhook.js";
|
||||
@@ -0,0 +1,64 @@
|
||||
# verify-webhook
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Wrapper around `@suaveplan/payment`'s `WebhookVerifier` tailored for the plugin's webhook route. Reads the request's `X-Signature` and `X-Timestamp` headers plus the raw body bytes, then performs HMAC + timestamp-window verification BEFORE any JSON parsing. The "verify before deserialise" ordering is critical — JSON-parsing untrusted bytes leaks the prototype-pollution and resource-exhaustion attack surface that a well-tuned attacker can use even when payload contents are eventually rejected.
|
||||
|
||||
Every failure path raises a `PaymentPluginError` with code `WEBHOOK_SIGNATURE_INVALID` (HMAC mismatch, missing header, age-out, parse error) or `WEBHOOK_TIMESTAMP_INVALID` (non-numeric or non-integer timestamp). The route handler maps both to HTTP 400 so the provider's webhook delivery loop can distinguish authenticated-rejected from infrastructure-failure (5xx).
|
||||
|
||||
## Features
|
||||
|
||||
- Header presence + emptiness check (rejects when `X-Signature` or `X-Timestamp` is missing/empty).
|
||||
- Numeric integer validation on `X-Timestamp` — non-integer values fail before HMAC compute.
|
||||
- `WebhookVerifier` delegation handles HMAC, constant-time comparison, and tolerance-window enforcement.
|
||||
- Optional `now` injection for deterministic tests.
|
||||
- Companion `signPaymentWebhook` helper computes the matching signature for replay-tooling and test fixtures.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { verifyPaymentWebhook } from "@suaveplan/api-plugin-payment/webhook/verify-webhook";
|
||||
|
||||
const { payload } = verifyPaymentWebhook({
|
||||
body: await c.req.text(),
|
||||
signature: c.req.header("X-Signature") ?? null,
|
||||
timestamp: c.req.header("X-Timestamp") ?? null,
|
||||
secret: config.webhookSecret,
|
||||
toleranceMs: config.webhookToleranceMs,
|
||||
algorithm: config.webhookAlgorithm,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
signPaymentWebhook,
|
||||
verifyPaymentWebhook,
|
||||
} from "@suaveplan/api-plugin-payment/webhook/verify-webhook";
|
||||
|
||||
// Construct + sign a webhook envelope in a test:
|
||||
const body = JSON.stringify({ id: "evt_1", type: "intent.captured", timestamp: 1, data: {} });
|
||||
const ts = Date.now();
|
||||
const sig = signPaymentWebhook({ body, timestamp: ts, secret, algorithm: "SHA-256" });
|
||||
await fetch("/payments/webhook", {
|
||||
method: "POST",
|
||||
headers: { "X-Signature": sig, "X-Timestamp": String(ts) },
|
||||
body,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `verifyPaymentWebhook(options): VerifiedWebhook` — verify an envelope; throws `PaymentPluginError` on any failure.
|
||||
- `signPaymentWebhook(args): string` — produce the matching HMAC signature.
|
||||
- `WebhookAlgorithmName` — `"SHA-256" | "SHA-384" | "SHA-512"`.
|
||||
- `VerifyPaymentWebhookOptions` — `{ body, signature, timestamp, secret, toleranceMs, algorithm, now? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The wrapper does not catch `PaymentPluginError` itself — only `PaymentError` and other thrown values — so `WEBHOOK_TIMESTAMP_INVALID` from the header-numeric check surfaces unchanged. The `now` injection point is critical for testability because timestamp-window failures depend on wall-clock drift; deterministic tests pin `now` to a value relative to the envelope's `timestamp` and exercise both the in-window and out-of-window paths without `setTimeout`.
|
||||
@@ -0,0 +1,206 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
PAYMENT_PLUGIN_CODES,
|
||||
PaymentPluginError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { signPaymentWebhook, verifyPaymentWebhook } from "./verify-webhook.js";
|
||||
|
||||
const SECRET = "shared-webhook-secret-32-chars-or-more";
|
||||
|
||||
function makeEnvelope(body: string, now: number) {
|
||||
const ts = now;
|
||||
const sig = signPaymentWebhook({
|
||||
body,
|
||||
timestamp: ts,
|
||||
secret: SECRET,
|
||||
algorithm: "SHA-256",
|
||||
});
|
||||
return { sig, ts };
|
||||
}
|
||||
|
||||
describe("verifyPaymentWebhook", () => {
|
||||
it("accepts a well-signed envelope", { timeout: 5000 }, () => {
|
||||
const body = JSON.stringify({
|
||||
id: "evt_1",
|
||||
type: "intent.captured",
|
||||
timestamp: 1,
|
||||
data: {},
|
||||
});
|
||||
const fixedNow = 1_700_000_000_000;
|
||||
const { sig, ts } = makeEnvelope(body, fixedNow);
|
||||
const result = verifyPaymentWebhook({
|
||||
body,
|
||||
signature: sig,
|
||||
timestamp: String(ts),
|
||||
secret: SECRET,
|
||||
toleranceMs: 300_000,
|
||||
algorithm: "SHA-256",
|
||||
now: () => fixedNow,
|
||||
});
|
||||
expect(result.payload.type).toBe("intent.captured");
|
||||
});
|
||||
|
||||
it("rejects a tampered signature", { timeout: 5000 }, () => {
|
||||
const body = JSON.stringify({
|
||||
id: "e",
|
||||
type: "x",
|
||||
timestamp: 1,
|
||||
data: {},
|
||||
});
|
||||
const fixedNow = 1_700_000_000_000;
|
||||
const { sig, ts } = makeEnvelope(body, fixedNow);
|
||||
const tampered = `${sig.slice(0, -1)}0`;
|
||||
expect(() =>
|
||||
verifyPaymentWebhook({
|
||||
body,
|
||||
signature: tampered,
|
||||
timestamp: String(ts),
|
||||
secret: SECRET,
|
||||
toleranceMs: 300_000,
|
||||
algorithm: "SHA-256",
|
||||
now: () => fixedNow,
|
||||
})
|
||||
).toThrow(PaymentPluginError);
|
||||
});
|
||||
|
||||
it("rejects an expired timestamp", { timeout: 5000 }, () => {
|
||||
const body = JSON.stringify({
|
||||
id: "e",
|
||||
type: "x",
|
||||
timestamp: 1,
|
||||
data: {},
|
||||
});
|
||||
const ts = 1_700_000_000_000;
|
||||
const sig = signPaymentWebhook({
|
||||
body,
|
||||
timestamp: ts,
|
||||
secret: SECRET,
|
||||
algorithm: "SHA-256",
|
||||
});
|
||||
try {
|
||||
verifyPaymentWebhook({
|
||||
body,
|
||||
signature: sig,
|
||||
timestamp: String(ts),
|
||||
secret: SECRET,
|
||||
toleranceMs: 1,
|
||||
algorithm: "SHA-256",
|
||||
now: () => ts + 1_000_000,
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(PaymentPluginError);
|
||||
expect((err as PaymentPluginError).pluginCode).toBe(
|
||||
PAYMENT_PLUGIN_CODES.WEBHOOK_SIGNATURE_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects missing signature header", { timeout: 5000 }, () => {
|
||||
try {
|
||||
verifyPaymentWebhook({
|
||||
body: "{}",
|
||||
signature: null,
|
||||
timestamp: "1700000000000",
|
||||
secret: SECRET,
|
||||
toleranceMs: 300_000,
|
||||
algorithm: "SHA-256",
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(PaymentPluginError);
|
||||
expect((err as PaymentPluginError).pluginCode).toBe(
|
||||
PAYMENT_PLUGIN_CODES.WEBHOOK_SIGNATURE_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects missing timestamp header", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
verifyPaymentWebhook({
|
||||
body: "{}",
|
||||
signature: "abcd",
|
||||
timestamp: null,
|
||||
secret: SECRET,
|
||||
toleranceMs: 300_000,
|
||||
algorithm: "SHA-256",
|
||||
})
|
||||
).toThrow(PaymentPluginError);
|
||||
});
|
||||
|
||||
it("rejects empty header values", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
verifyPaymentWebhook({
|
||||
body: "{}",
|
||||
signature: "",
|
||||
timestamp: "",
|
||||
secret: SECRET,
|
||||
toleranceMs: 300_000,
|
||||
algorithm: "SHA-256",
|
||||
})
|
||||
).toThrow(PaymentPluginError);
|
||||
});
|
||||
|
||||
it("rejects non-numeric timestamp", { timeout: 5000 }, () => {
|
||||
try {
|
||||
verifyPaymentWebhook({
|
||||
body: "{}",
|
||||
signature: "abcd",
|
||||
timestamp: "not-a-number",
|
||||
secret: SECRET,
|
||||
toleranceMs: 300_000,
|
||||
algorithm: "SHA-256",
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as PaymentPluginError).pluginCode).toBe(
|
||||
PAYMENT_PLUGIN_CODES.WEBHOOK_TIMESTAMP_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects floating-point timestamp", { timeout: 5000 }, () => {
|
||||
try {
|
||||
verifyPaymentWebhook({
|
||||
body: "{}",
|
||||
signature: "abcd",
|
||||
timestamp: "1.5",
|
||||
secret: SECRET,
|
||||
toleranceMs: 300_000,
|
||||
algorithm: "SHA-256",
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as PaymentPluginError).pluginCode).toBe(
|
||||
PAYMENT_PLUGIN_CODES.WEBHOOK_TIMESTAMP_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps non-PaymentError throws as WEBHOOK_SIGNATURE_INVALID", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const body = "{not json";
|
||||
const fixedNow = 1_700_000_000_000;
|
||||
const sig = signPaymentWebhook({
|
||||
body,
|
||||
timestamp: fixedNow,
|
||||
secret: SECRET,
|
||||
algorithm: "SHA-256",
|
||||
});
|
||||
try {
|
||||
verifyPaymentWebhook({
|
||||
body,
|
||||
signature: sig,
|
||||
timestamp: String(fixedNow),
|
||||
secret: SECRET,
|
||||
toleranceMs: 300_000,
|
||||
algorithm: "SHA-256",
|
||||
now: () => fixedNow,
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(PaymentPluginError);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { type VerifiedWebhook, WebhookVerifier } from "@suaveplan/payment";
|
||||
import {
|
||||
PAYMENT_PLUGIN_CODES,
|
||||
PaymentPluginError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/** HMAC algorithm names accepted by the verifier. */
|
||||
export type WebhookAlgorithmName = "SHA-256" | "SHA-384" | "SHA-512";
|
||||
|
||||
/** Inputs to {@link verifyPaymentWebhook}. */
|
||||
export interface VerifyPaymentWebhookOptions {
|
||||
readonly body: string;
|
||||
readonly signature: string | null;
|
||||
readonly timestamp: string | null;
|
||||
readonly secret: string;
|
||||
readonly toleranceMs: number;
|
||||
readonly algorithm: WebhookAlgorithmName;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an inbound payment-provider webhook envelope. Reads the raw
|
||||
* request body, the `X-Signature` header, and the `X-Timestamp` header,
|
||||
* then delegates to `@suaveplan/payment`'s {@link WebhookVerifier} for
|
||||
* HMAC + timestamp-window verification.
|
||||
*
|
||||
* The function NEVER deserialises the body before the HMAC check — the
|
||||
* point of verification is to refuse processing of un-trusted bytes.
|
||||
*
|
||||
* Throws a {@link PaymentPluginError} on every failure path so the
|
||||
* route handler can branch on `pluginCode` for status-code mapping:
|
||||
*
|
||||
* - missing/empty `X-Signature` or `X-Timestamp` → `WEBHOOK_SIGNATURE_INVALID`
|
||||
* - non-numeric `X-Timestamp` → `WEBHOOK_TIMESTAMP_INVALID`
|
||||
* - HMAC mismatch / timestamp outside window / parse-error → `WEBHOOK_SIGNATURE_INVALID`
|
||||
*
|
||||
* Returns the verifier's parsed payload on success.
|
||||
*/
|
||||
export function verifyPaymentWebhook<T = unknown>(
|
||||
options: VerifyPaymentWebhookOptions
|
||||
): VerifiedWebhook<T> {
|
||||
const { body, signature, timestamp, secret, toleranceMs, algorithm } =
|
||||
options;
|
||||
if (
|
||||
signature === null ||
|
||||
signature.length === 0 ||
|
||||
timestamp === null ||
|
||||
timestamp.length === 0
|
||||
) {
|
||||
throw new PaymentPluginError(
|
||||
PAYMENT_PLUGIN_CODES.WEBHOOK_SIGNATURE_INVALID,
|
||||
"Missing X-Signature or X-Timestamp header",
|
||||
{ context: { statusCode: 400 } }
|
||||
);
|
||||
}
|
||||
const tsNum = Number(timestamp);
|
||||
if (!Number.isFinite(tsNum) || !Number.isInteger(tsNum)) {
|
||||
throw new PaymentPluginError(
|
||||
PAYMENT_PLUGIN_CODES.WEBHOOK_TIMESTAMP_INVALID,
|
||||
"X-Timestamp is not an integer",
|
||||
{ context: { statusCode: 400 } }
|
||||
);
|
||||
}
|
||||
const verifier = new WebhookVerifier({
|
||||
secret,
|
||||
toleranceMs,
|
||||
algorithm,
|
||||
...(options.now ? { now: options.now } : {}),
|
||||
});
|
||||
try {
|
||||
return verifier.verify<T>(body, signature, tsNum);
|
||||
} catch (err) {
|
||||
// `WebhookVerifier.verify` only throws `Error` subclasses
|
||||
// (`PaymentError` / `SyntaxError` from JSON.parse), so casting
|
||||
// is safe — there is no non-`Error` throwable path through the
|
||||
// verifier's public surface.
|
||||
throw new PaymentPluginError(
|
||||
PAYMENT_PLUGIN_CODES.WEBHOOK_SIGNATURE_INVALID,
|
||||
(err as Error).message,
|
||||
{ context: { statusCode: 400 }, cause: err }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper — sign a payload with the same HMAC scheme the verifier
|
||||
* expects. Used by tests and by replay-tooling that constructs a
|
||||
* webhook envelope from inside the process.
|
||||
*/
|
||||
export function signPaymentWebhook(args: {
|
||||
readonly body: string;
|
||||
readonly timestamp: number;
|
||||
readonly secret: string;
|
||||
readonly algorithm: WebhookAlgorithmName;
|
||||
}): string {
|
||||
const verifier = new WebhookVerifier({
|
||||
secret: args.secret,
|
||||
algorithm: args.algorithm,
|
||||
});
|
||||
return verifier.sign(args.body, args.timestamp);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin-payment/tsconfig.json
Normal file
12
packages/api-stack/api/api-plugin-payment/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin-payment/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-plugin-payment/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
37
packages/api-stack/api/api-plugin-payment/vite.config.ts
Normal file
37
packages/api-stack/api/api-plugin-payment/vite.config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/api-plugin-payment",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
/^@suaveplan\//,
|
||||
/^@opentelemetry\//,
|
||||
/^@hono\//,
|
||||
"hono",
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
33
packages/api-stack/api/api-plugin-payment/vitest.config.ts
Normal file
33
packages/api-stack/api/api-plugin-payment/vitest.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 60000,
|
||||
hookTimeout: 120000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user