Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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 @@
|
||||
# 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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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).
|
||||
@@ -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`.
|
||||
Reference in New Issue
Block a user