first commit
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user