first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.