Initial Commit
This commit is contained in:
119
openspec/archive/2026-05-19-add-api-plugin-payment/proposal.md
Normal file
119
openspec/archive/2026-05-19-add-api-plugin-payment/proposal.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Add @suaveplan/api-plugin-payment — Hono plugin wrapping @suaveplan/payment + provider siblings
|
||||
|
||||
**Package**: `@suaveplan/api-plugin-payment` (NEW, server-tier)
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package
|
||||
**Position**: builds on `@suaveplan/api`, `@suaveplan/api-plugin`, `@suaveplan/api-openapi`, `@suaveplan/api-plugin-auth`, `@suaveplan/payment`, optional `@suaveplan/idempotency`.
|
||||
|
||||
- **Spec delta**: ./specs/api-plugin-payment/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
Genesis ships `@suaveplan/payment` (universal-tier `PaymentProvider` interface, `PaymentEngine`, `WebhookVerifier`, `Refund`, full lifecycle) and four concrete provider siblings (`@suaveplan/payment-provider-{stripe,adyen,paypal,square,mollie}`). What's missing is an HTTP layer that mounts the lifecycle as Zod-typed OpenAPI routes on top of `@suaveplan/api`, gates them behind `requireAuth()` from `@suaveplan/api-plugin-auth`, scopes intents per-user (anti-enumeration), enforces `Idempotency-Key` on mutation routes, verifies provider-specific webhook HMAC before deserializing payloads, and registers a typed `paymentService` slot every downstream consumer reads.
|
||||
|
||||
This package fills that gap as a `definePlugin` consumer. It is the canonical **payment** exemplar of the 3-way plug-and-play contract (Zod schemas at boundary + OpenAPI route registration + service-registry slot) every `@suaveplan/api-plugin-*` package follows. The plugin is provider-agnostic — the operator constructs the `PaymentProvider` (Stripe, Adyen, PayPal, Square, Mollie, or a custom impl) and injects it at factory time. No default provider is chosen by the plugin.
|
||||
|
||||
## What changes
|
||||
|
||||
Net-new package at `0.1.0`. ADDED:
|
||||
|
||||
### Plugin factory + descriptor surface
|
||||
|
||||
- `createPaymentPlugin(options: PaymentPluginOptions): PluginDescriptor<PaymentPluginConfig>` — Zod-validated factory returning a `PluginDescriptor` from `@suaveplan/api-plugin`. The descriptor implements the canonical 6-step `onInit`:
|
||||
1. Resolve `app` + `openAPIRegistry` from `ServiceRegistry`.
|
||||
2. Resolve the caller-supplied `PaymentProvider` from `options.provider` (no default — caller injects).
|
||||
3. Construct a `PaymentService` wrapping `provider` + the `PaymentIntentStore` (in-memory default) so ownership scoping flows through every route.
|
||||
4. Register `paymentService` in the host `ServiceRegistry` (typed slot) exposing the full lifecycle.
|
||||
5. Register 6 OpenAPI routes via `definePluginRoutes` (mounted at `routePrefix`, default `/payments`) + 1 webhook receiver (`POST /payments/webhook`) mounted as a separate sub-router because webhook verification reads the raw request body BEFORE Zod parsing.
|
||||
6. Log readiness via `ctx.logger.info`.
|
||||
- `PaymentPluginOptions` (validated via `PaymentPluginOptionsSchema`): `{ provider: PaymentProvider; routePrefix?: string (default "/payments"); store?: PaymentIntentStore; webhookSecret: string; webhookToleranceMs?: number (default 300_000); webhookAlgorithm?: WebhookAlgorithm (default "SHA-256"); idempotencyStore?: IdempotencyStore; logger?: LoggerLike; clock?: Clock }`.
|
||||
|
||||
### `PaymentService` (typed ServiceRegistry slot)
|
||||
|
||||
- `createPaymentService({ provider, store, clock })` returns `PaymentService` exposing the full lifecycle: `createIntent`, `confirmIntent`, `captureIntent`, `cancelIntent`, `refund`, `getIntent`, `listIntents`. Every method is **owner-scoped** — it accepts a `Principal.id` (and optional `tenantId`) and rejects access to foreign intents with a 404 (anti-enumeration). The service owns the `PaymentIntentStore` write-side so provider-side IDs map back to owner ids.
|
||||
|
||||
### `PaymentIntentStore`
|
||||
|
||||
- `PaymentIntentStore` interface — record-side persistence mapping a provider-issued `PaymentIntent.id` back to its `ownerId` + optional `tenantId`. `MemoryPaymentIntentStore` is the in-process default; operators inject any other impl that satisfies the contract (SurrealDB, Postgres, Redis).
|
||||
|
||||
### Routes (Zod-typed OpenAPI surface)
|
||||
|
||||
All mutation routes are `requireAuth()`-gated and scope to `c.get("user").id`:
|
||||
|
||||
- `POST /payments/intents` — body: `CreatePaymentIntentBody` (Money via `MoneySchema`); response: `PaymentIntentResponse`. Honours `Idempotency-Key` header.
|
||||
- `GET /payments/intents` — query: `ListPaymentIntentsQuery` (limit/cursor/status filter); response: `PaymentIntentListResponse` (only intents owned by the requester).
|
||||
- `GET /payments/intents/{id}` — params: `PaymentIntentIdParam`; response: `PaymentIntentResponse`. Foreign intent → 404 anti-enumeration.
|
||||
- `POST /payments/intents/{id}/confirm` — empty body; response: `PaymentIntentResponse`.
|
||||
- `POST /payments/intents/{id}/capture` — body: `CaptureBody` (optional partial amount); response: `PaymentIntentResponse`.
|
||||
- `POST /payments/intents/{id}/cancel` — empty body; response: `PaymentIntentResponse`.
|
||||
- `POST /payments/intents/{id}/refund` — body: `RefundBody`; response: `RefundResponse`. Honours `Idempotency-Key` header.
|
||||
|
||||
Plus the unauthenticated webhook receiver:
|
||||
|
||||
- `POST /payments/webhook` — verifies the provider-specific HMAC (`X-Signature` / `X-Timestamp`) against `options.webhookSecret` BEFORE deserialising the body, replays through `EventBus` when `api-plugin-events` is mounted, otherwise applies updates via the `paymentService` directly.
|
||||
|
||||
### Zod schemas (boundary validation) — exposed at `./schemas/*`
|
||||
|
||||
- `./schemas/money` — `MoneySchema` (`{ amount: z.coerce.bigint(), currency: z.string().min(3).max(8) }`).
|
||||
- `./schemas/options` — `PaymentPluginOptionsSchema`.
|
||||
- `./schemas/routes` — request/response per route (`CreatePaymentIntentBodySchema`, `CaptureBodySchema`, `RefundBodySchema`, `PaymentIntentIdParamSchema`, `ListPaymentIntentsQuerySchema`, `PaymentIntentResponseSchema`, `RefundResponseSchema`, `PaymentIntentListResponseSchema`, `WebhookResponseSchema`).
|
||||
|
||||
### Errors + observability
|
||||
|
||||
- `PaymentPluginError extends SuaveplanError<PaymentPluginErrorCode>` via `defineCodes("PAYMENT_PLUGIN", [...])`. **12 codes**: `INVALID_INPUT`, `NOT_FOUND`, `FORBIDDEN`, `PROVIDER_ERROR`, `WEBHOOK_SIGNATURE_INVALID`, `WEBHOOK_TIMESTAMP_INVALID`, `IDEMPOTENCY_CONFLICT`, `INVALID_STATE`, `AMOUNT_MISMATCH`, `CURRENCY_MISMATCH`, `REFUND_EXCEEDS_CAPTURED`, `PROVIDER_NOT_CONFIGURED`, `INTENT_CREATION_FAILED`, `SERVICE_NOT_AVAILABLE`, `ROUTE_PREFIX_COLLISION`.
|
||||
- OpenTelemetry: 5 counters and 1 histogram —
|
||||
- `payments.intents_created_total{outcome,provider}`
|
||||
- `payments.captures_total{outcome,provider}`
|
||||
- `payments.refunds_total{outcome,provider}`
|
||||
- `payments.webhook_received_total{outcome,provider}`
|
||||
- `payments.webhook_signature_failures_total{provider}`
|
||||
- `payments.intent_duration_ms{provider}` (histogram)
|
||||
|
||||
### Subpath exports
|
||||
|
||||
`.`, `./plugin/plugin`, `./middleware/idempotency-middleware`, `./services/payment-service`, `./store/memory-store`, `./store/payment-intent-store`, `./webhook/verify-webhook`, `./schemas/money`, `./schemas/options`, `./schemas/routes`, `./routes/build-routes`, `./otel/instruments`, `./error-codes/error-codes`, `./types/types`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Front-end UI (checkout form, 3-D Secure challenge UI, dispute evidence upload). Backend only.
|
||||
- Per-tenant provider routing (multi-provider `ProviderRouter`) — single injected provider for now; multi-provider routing is a follow-up `api-plugin-payment-router`.
|
||||
- Saved payment methods / SetupIntents — covered by a follow-up `api-plugin-payment-methods`.
|
||||
- Subscription billing — covered by `api-plugin-subscription`.
|
||||
- Dispute workflow endpoints — covered by a follow-up `api-plugin-payment-disputes`.
|
||||
- Reconciliation export endpoints — covered by `api-plugin-export` + `@suaveplan/payment`'s `PaymentReconciliation` directly.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This package is **genesis-original** with design intent drawn from:
|
||||
|
||||
- The genesis `@suaveplan/api-plugin` `PluginContext` + `ServiceRegistry`.
|
||||
- The genesis `@suaveplan/api-openapi` `definePluginRoutes` + `RouteBuilder`.
|
||||
- The genesis `@suaveplan/api-plugin-auth` plug-and-play exemplar (3-step lifecycle + ContextVariableMap augmentation).
|
||||
- The legacy `enterprise/api-plugin-payment.md` enterprise plan (canonical source) — section 1 (Purpose), section 3 (Required improvements), section 4 (Feature-rich enhancements 1, 9).
|
||||
- `@suaveplan/payment` — `PaymentProvider` interface, `PaymentIntent`, `Refund`, `VALID_TRANSITIONS`, `WebhookVerifier`.
|
||||
- `@suaveplan/idempotency` — `IdempotencyGuard`, `MemoryIdempotencyStore` for the `Idempotency-Key` middleware.
|
||||
|
||||
| Requirement | Source |
|
||||
|---|---|
|
||||
| `definePlugin` consumer shape | `@suaveplan/api-plugin` |
|
||||
| `requireAuth()` gating, `c.get("user")` typed Principal | `@suaveplan/api-plugin-auth` |
|
||||
| `definePluginRoutes` route registration | `@suaveplan/api-openapi` |
|
||||
| `PaymentProvider` interface, `PaymentIntent`, `Refund` | `@suaveplan/payment/types/payment` |
|
||||
| Webhook HMAC verification + timestamp replay protection | `@suaveplan/payment/webhook/webhook` |
|
||||
| `Idempotency-Key` middleware | `@suaveplan/idempotency` |
|
||||
| Owner-scoped lookups + 404 anti-enumeration | legacy `enterprise/api-plugin-payment.md` §1 + §3 ("PCI posture") + genesis principle |
|
||||
| 12 `PAYMENT_PLUGIN_*` codes | legacy `enterprise/api-plugin-payment.md` §3 ("Typed errors") |
|
||||
| 5 counters + 1 histogram | legacy `enterprise/api-plugin-payment.md` §3 ("Observability") |
|
||||
| 3-way plug-and-play | genesis architectural principle (verbatim from `add-api-plugin-auth`) |
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/api-plugin-payment` to enumerate. Net-new package; no dependents today.
|
||||
|
||||
- No dependents (verified pre-shipment — net-new package; future consumers will be `api-plugin-payment-router` / `api-plugin-payment-methods` / `api-plugin-payment-disputes`).
|
||||
|
||||
### Catalog additions required
|
||||
|
||||
None — `hono`, `@hono/zod-openapi`, `zod`, `tslib`, `@opentelemetry/api`, `@vitest/coverage-istanbul`, `vite`, `vite-plugin-dts`, `vitest`, `fast-check` are all already pinned in the root catalog.
|
||||
@@ -0,0 +1,157 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: api-plugin-payment (add-api-plugin-payment)
|
||||
|
||||
**Package**: `@suaveplan/api-plugin-payment`
|
||||
**Status**: PROPOSED
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: createPaymentPlugin returns a Zod-validated PluginDescriptor
|
||||
|
||||
The package SHALL export `createPaymentPlugin(options): PluginDescriptor<PaymentPluginConfig>` that validates `options` against `PaymentPluginOptionsSchema` (Zod) at the factory boundary. Invalid input SHALL raise `PaymentPluginError(INVALID_INPUT)` carrying the `ZodError` as `cause`. The returned descriptor SHALL declare `name: "payment"`, the resolved `configSchema`, and the 4-phase lifecycle hooks.
|
||||
|
||||
#### Scenario: factory produces a valid descriptor
|
||||
|
||||
- **WHEN** `createPaymentPlugin({ provider, webhookSecret: "s" })` is called with a real `PaymentProvider` and a non-empty webhook secret
|
||||
- **THEN** the returned descriptor satisfies `PluginDescriptor<PaymentPluginConfig>` with `name === "payment"` and at least `onInit` defined
|
||||
|
||||
#### Scenario: invalid options raise PAYMENT_PLUGIN.INVALID_INPUT
|
||||
|
||||
- **WHEN** `createPaymentPlugin({} as never)` is called (missing `provider` + `webhookSecret`)
|
||||
- **THEN** `PaymentPluginError(INVALID_INPUT)` is thrown with `cause instanceof ZodError`
|
||||
|
||||
**Verified by:** `src/plugin/plugin.test.ts::"createPaymentPlugin returns valid PluginDescriptor"` AND `src/plugin/plugin.test.ts::"invalid options raise PAYMENT_PLUGIN.INVALID_INPUT"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 6-step onInit lifecycle
|
||||
|
||||
The descriptor's `onInit(ctx)` hook SHALL execute the canonical 6-step flow:
|
||||
|
||||
1. Resolve `app` from `ServiceRegistry`; missing `app` SHALL throw `PaymentPluginError(SERVICE_NOT_AVAILABLE)` carrying `context.missing = ["app"]`.
|
||||
2. Resolve the configured `PaymentProvider` from the caller-supplied `options.provider` (no default — caller injects).
|
||||
3. Construct the `PaymentService` wrapping `provider` + the `PaymentIntentStore` (default `MemoryPaymentIntentStore`).
|
||||
4. Register `paymentService` in the `ServiceRegistry` (typed slot via module augmentation).
|
||||
5. Register the 6 owner-scoped OpenAPI routes via `definePluginRoutes` at `routePrefix` (default `/payments`). Mount the webhook receiver (`POST /payments/webhook`) as a separate sub-router that reads the raw body before Zod parsing.
|
||||
6. Log readiness via `ctx.logger.info`.
|
||||
|
||||
#### Scenario: missing app raises SERVICE_NOT_AVAILABLE
|
||||
|
||||
- **WHEN** `onInit` runs without `app` registered in the host `ServiceRegistry`
|
||||
- **THEN** `PaymentPluginError(SERVICE_NOT_AVAILABLE)` is thrown with `context.missing` including `"app"`
|
||||
|
||||
#### Scenario: routePrefix collision raises ROUTE_PREFIX_COLLISION
|
||||
|
||||
- **WHEN** another plugin has already registered routes under the same prefix
|
||||
- **THEN** `PaymentPluginError(ROUTE_PREFIX_COLLISION)` is thrown with `context.prefix` set to the offending value
|
||||
|
||||
**Verified by:** `src/plugin/plugin.test.ts::"onInit executes the 6-step lifecycle"` AND `src/plugin/plugin.test.ts::"missing app raises SERVICE_NOT_AVAILABLE"` AND `src/plugin/plugin.test.ts::"routePrefix collision raises ROUTE_PREFIX_COLLISION"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 6 owner-scoped routes + 1 webhook receiver, all Zod-typed
|
||||
|
||||
The package SHALL export `buildPaymentRoutes({service, idempotencyMiddleware})` returning route entries for:
|
||||
|
||||
- `POST /intents` (idempotent), `GET /intents`, `GET /intents/{id}`,
|
||||
- `POST /intents/{id}/confirm`, `POST /intents/{id}/capture`, `POST /intents/{id}/cancel`,
|
||||
- `POST /intents/{id}/refund` (idempotent).
|
||||
|
||||
Each route SHALL be `requireAuth()`-gated and SHALL scope operations to `c.get("user").id`. Foreign-intent access SHALL return HTTP 404 (anti-enumeration: a request from user A for an intent owned by user B is indistinguishable from a missing intent).
|
||||
|
||||
The webhook receiver `POST /webhook` SHALL be mounted as a separate sub-router that reads the raw request body, verifies the provider HMAC (`X-Signature` + `X-Timestamp`) against `options.webhookSecret` via `WebhookVerifier` from `@suaveplan/payment`, and ONLY after verification deserializes the body and dispatches.
|
||||
|
||||
#### Scenario: owner-scoped GET on a foreign intent returns 404
|
||||
|
||||
- **WHEN** user A creates an intent and user B fetches `GET /intents/{idOfA}` with B's valid auth cookie
|
||||
- **THEN** the response is HTTP 404 (the body envelope reports `PAYMENT_PLUGIN.NOT_FOUND`)
|
||||
|
||||
#### Scenario: webhook with invalid HMAC is rejected before deserialisation
|
||||
|
||||
- **WHEN** `POST /webhook` is invoked with a tampered signature
|
||||
- **THEN** the response is HTTP 400 carrying `WEBHOOK_SIGNATURE_INVALID` AND `payments.webhook_signature_failures_total` is incremented AND the body is NOT JSON-parsed
|
||||
|
||||
#### Scenario: webhook with valid HMAC is accepted
|
||||
|
||||
- **WHEN** `POST /webhook` is invoked with a signature computed from the configured secret over `timestamp.body`
|
||||
- **THEN** the response is HTTP 200 AND `payments.webhook_received_total{outcome:"accepted"}` is incremented
|
||||
|
||||
**Verified by:** `src/routes/build-routes.test.ts::"every route is requireAuth-gated"` AND `tests/integration/full-lifecycle.test.ts::"foreign intent returns 404 anti-enumeration"` AND `tests/integration/webhook.test.ts::"valid HMAC accepted"` AND `tests/integration/webhook.test.ts::"invalid HMAC rejected before deserialisation"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PaymentService registered in ServiceRegistry with owner-scoped lifecycle
|
||||
|
||||
`onInit` SHALL call `ctx.registerService("paymentService", createPaymentService({provider, store, clock}))`. The service SHALL expose `createIntent`, `confirmIntent`, `captureIntent`, `cancelIntent`, `refund`, `getIntent`, `listIntents`, each accepting an `ownerId` and rejecting access to non-owned intents with `PaymentPluginError(NOT_FOUND)` (statusCode 404). The package SHALL augment `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` with `paymentService: PaymentService`.
|
||||
|
||||
#### Scenario: downstream plugin reads paymentService with type safety
|
||||
|
||||
- **WHEN** a sibling plugin's `onInit(ctx)` calls `ctx.getService("paymentService")`
|
||||
- **THEN** the return type is `PaymentService | undefined` (no cast) AND the runtime value exposes all 7 lifecycle methods
|
||||
|
||||
#### Scenario: getIntent for foreign owner returns NOT_FOUND
|
||||
|
||||
- **WHEN** `service.getIntent({ id: "abc", ownerId: "userB" })` is called for an intent owned by `userA`
|
||||
- **THEN** `PaymentPluginError(NOT_FOUND)` is thrown with `statusCode === 404`
|
||||
|
||||
**Verified by:** `src/services/payment-service.test.ts::"getIntent for foreign owner returns NOT_FOUND"` AND `src/api-plugin-payment.spec-conformance.test.ts::"ServiceRegistry slot typed as PaymentService"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Idempotency-Key middleware on POST /intents and POST /refund
|
||||
|
||||
The package SHALL export `createPaymentIdempotencyMiddleware({store})` and apply it to `POST /intents` and `POST /intents/{id}/refund`. When the request carries an `Idempotency-Key` header, the middleware SHALL replay the previously-recorded response body for the same `(ownerId, key)` pair instead of invoking the provider a second time. When the same key is used with a DIFFERENT request body, the middleware SHALL raise `PaymentPluginError(IDEMPOTENCY_CONFLICT)` (statusCode 409).
|
||||
|
||||
#### Scenario: same Idempotency-Key returns the same intent
|
||||
|
||||
- **WHEN** `POST /intents` is called twice with identical body and identical `Idempotency-Key`
|
||||
- **THEN** the second call returns the same `intent.id` AND the provider was invoked exactly once
|
||||
|
||||
#### Scenario: different body under same key raises IDEMPOTENCY_CONFLICT
|
||||
|
||||
- **WHEN** `POST /intents` is called with the same `Idempotency-Key` but a different amount
|
||||
- **THEN** the response is HTTP 409 carrying `PAYMENT_PLUGIN.IDEMPOTENCY_CONFLICT`
|
||||
|
||||
**Verified by:** `src/middleware/idempotency-middleware.test.ts::"same key + body returns cached response"` AND `tests/integration/idempotency.test.ts::"second create with same key returns same intent"` AND `tests/integration/idempotency.test.ts::"different body under same key raises 409"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 15 PAYMENT_PLUGIN_* codes; every code thrown from a production path
|
||||
|
||||
`PAYMENT_PLUGIN_CODES` SHALL define exactly 15 codes: `INVALID_INPUT`, `NOT_FOUND`, `FORBIDDEN`, `PROVIDER_ERROR`, `WEBHOOK_SIGNATURE_INVALID`, `WEBHOOK_TIMESTAMP_INVALID`, `IDEMPOTENCY_CONFLICT`, `INVALID_STATE`, `AMOUNT_MISMATCH`, `CURRENCY_MISMATCH`, `REFUND_EXCEEDS_CAPTURED`, `PROVIDER_NOT_CONFIGURED`, `INTENT_CREATION_FAILED`, `SERVICE_NOT_AVAILABLE`, `ROUTE_PREFIX_COLLISION`. Every code SHALL be raised by ≥1 production call site.
|
||||
|
||||
**Verified by:** `src/api-plugin-payment.spec-conformance.test.ts::"every PAYMENT_PLUGIN_* code is thrown from a production path"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 5 OTel counters + 1 histogram emit per operation
|
||||
|
||||
The package SHALL register counters `payments.intents_created_total{outcome,provider}`, `payments.captures_total{outcome,provider}`, `payments.refunds_total{outcome,provider}`, `payments.webhook_received_total{outcome,provider}`, `payments.webhook_signature_failures_total{provider}`, AND histogram `payments.intent_duration_ms{provider}`. Every route SHALL emit the matching counter regardless of success/failure; intent-creation SHALL also record the histogram.
|
||||
|
||||
#### Scenario: createIntent emits counter + histogram
|
||||
|
||||
- **WHEN** `POST /intents` succeeds against the in-memory MeterProvider
|
||||
- **THEN** `payments.intents_created_total{outcome:"success"}` records one observation AND `payments.intent_duration_ms` records one observation
|
||||
|
||||
#### Scenario: webhook signature failure increments dedicated counter
|
||||
|
||||
- **WHEN** `POST /webhook` is called with an invalid signature
|
||||
- **THEN** `payments.webhook_signature_failures_total` records one observation AND `payments.webhook_received_total{outcome:"rejected"}` records one observation
|
||||
|
||||
**Verified by:** `src/otel/instruments.test.ts::"counter + histogram emit per intent create"` AND `tests/integration/webhook.test.ts::"invalid HMAC rejected before deserialisation"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Server-tier package shape, full 3-way plug-and-play
|
||||
|
||||
The package SHALL run in Bun + Node. `package.json` SHALL declare 14 subpath exports per feature module (covering `./plugin/plugin`, `./middleware/idempotency-middleware`, `./services/payment-service`, `./store/payment-intent-store`, `./store/memory-store`, `./webhook/verify-webhook`, `./schemas/money`, `./schemas/options`, `./schemas/routes`, `./routes/build-routes`, `./otel/instruments`, `./error-codes/error-codes`, `./types/types`). Every implementation `.ts` SHALL have a sibling `.md` ≥200 words. No `export *` in feature barrels. 100/100/100/100 coverage. Integration test SHALL exercise: (a) plugin mounted alongside `createAuthPlugin` against `OpenAPIHono`, (b) a `FakePaymentProvider` implementing the real `PaymentProvider` interface, (c) the full lifecycle: create → confirm → capture → refund → get, (d) cross-user isolation, (e) Idempotency-Key replay, (f) webhook HMAC accept + reject.
|
||||
|
||||
#### Scenario: full integration boot
|
||||
|
||||
- **WHEN** `tests/integration/full-lifecycle.test.ts` constructs a real `OpenAPIHono`, registers `createAuthPlugin` + `createPaymentPlugin({provider: new FakePaymentProvider()})`, drives the full lifecycle as authenticated user A, then attempts foreign-intent read as user B
|
||||
- **THEN** every step succeeds, the final `GET /intents/{id}` for user A shows `status:"captured"` then `partially_refunded` after a partial refund, AND user B's `GET /intents/{idOfA}` returns 404
|
||||
|
||||
**Verified by:** `tests/integration/full-lifecycle.test.ts::"full lifecycle: create → confirm → capture → refund → get"` AND `tests/integration/full-lifecycle.test.ts::"foreign intent returns 404 anti-enumeration"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
58
openspec/archive/2026-05-19-add-api-plugin-payment/tasks.md
Normal file
58
openspec/archive/2026-05-19-add-api-plugin-payment/tasks.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Tasks — add-api-plugin-payment
|
||||
|
||||
## 1. Scaffold
|
||||
|
||||
- [x] 1.1 Create `packages/api/api-plugin-payment/` with `src/{plugin,middleware,error-codes,otel,routes,services,schemas/{routes},store,types,webhook,idempotency}/` + `tests/{setup,integration}/`
|
||||
- [x] 1.2 `package.json` v0.1.0. Deps: `@suaveplan/api-plugin` `@suaveplan/api-openapi` `@suaveplan/api-plugin-auth` `@suaveplan/payment` `@suaveplan/idempotency` `@suaveplan/money` `@suaveplan/error` `@suaveplan/types` `@suaveplan/events` `tslib`, `hono` `@hono/zod-openapi` (`catalog:server`), `zod` (`catalog:forms`), `@opentelemetry/api`
|
||||
- [x] 1.3 14 subpath exports per the proposal
|
||||
- [x] 1.4 tsconfigs + vite + vitest (100/100/100/100; testTimeout 60000)
|
||||
- [x] 1.5 `tests/setup/polyfills.ts` — install in-memory `MeterProvider`
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 `src/error-codes/error-codes.{ts,test.ts,md,index.ts}` — 15 PAYMENT_PLUGIN.* codes + `PaymentPluginError` class with `statusCode` discriminant
|
||||
- [x] 2.2 `src/types/types.{ts,md,index.ts}` — `PaymentPluginOptions`, `PaymentPluginConfig`
|
||||
- [x] 2.3 `src/otel/instruments.{ts,test.ts,md,index.ts}` — 5 counters + 1 histogram
|
||||
- [x] 2.4 `src/store/payment-intent-store.{ts,md,index.ts}` — `PaymentIntentStore` interface + `OwnedIntent` type
|
||||
- [x] 2.5 `src/store/memory-store.{ts,test.ts,md}` — `MemoryPaymentIntentStore` default impl
|
||||
- [x] 2.6 `src/schemas/money.{ts,test.ts,md,index.ts}` — `MoneySchema` (bigint via z.coerce + currency)
|
||||
- [x] 2.7 `src/schemas/options.{ts,test.ts,md}` — `PaymentPluginOptionsSchema`
|
||||
- [x] 2.8 `src/schemas/routes/<name>.{ts,md}` per route (create-intent, list-intents, get-intent, confirm-intent, capture-intent, cancel-intent, refund, webhook) + `schemas/routes/routes.test.ts` + `schemas/routes/index.ts`
|
||||
- [x] 2.9 `src/services/payment-service.{ts,test.ts,md,index.ts}` — `createPaymentService({provider, store, clock})` + `PaymentService` interface; owner-scoped CRUD + 404 anti-enumeration
|
||||
- [x] 2.10 `src/services/registry-augmentation.{ts,md}` — augments `ServiceRegistry` with `paymentService` + host slots
|
||||
- [x] 2.11 `src/middleware/idempotency-middleware.{ts,test.ts,md,index.ts}` — Idempotency-Key middleware backed by `IdempotencyGuard`
|
||||
- [x] 2.12 `src/webhook/verify-webhook.{ts,test.ts,md,index.ts}` — HMAC verify wrapper around `@suaveplan/payment`'s `WebhookVerifier`; reads raw body, dispatches via `EventBus` or directly
|
||||
- [x] 2.13 `src/routes/build-routes.{ts,test.ts,md,index.ts}` — `buildPaymentRoutes({service, idempotencyMiddleware})` returning `PluginRouteEntry[]` for the 6 owner-scoped routes
|
||||
- [x] 2.14 `src/plugin/plugin.{ts,test.ts,md,index.ts}` — `createPaymentPlugin(options)` + 6-step onInit
|
||||
- [x] 2.15 `src/index.ts` — named re-exports only
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 Co-located `*.test.ts` per impl module
|
||||
- [x] 3.2 `src/api-plugin-payment.spec-conformance.test.ts` — every PAYMENT_PLUGIN.* code thrown; `expectTypeOf` on `PaymentService`, `PaymentIntentStore`, ServiceRegistry augmentation
|
||||
- [x] 3.3 `tests/integration/full-lifecycle.test.ts` — real api + this plugin + auth plugin + `FakePaymentProvider implements PaymentProvider`; createIntent → confirm → capture → refund → getIntent + cross-user isolation
|
||||
- [x] 3.4 `tests/integration/idempotency.test.ts` — same Idempotency-Key returns same intent (no duplicate)
|
||||
- [x] 3.5 `tests/integration/webhook.test.ts` — valid HMAC accepted; invalid rejected (signature failure counter increments)
|
||||
- [x] 3.6 `tests/integration/error-paths.test.ts` — currency mismatch + amount mismatch + foreign intent 404 anti-enumeration
|
||||
- [x] 3.7 All tests via `@suaveplan/testing/runner`; `{ timeout: 5000 }` (60000 for integration)
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` per impl (≥200 words)
|
||||
- [x] 4.2 `README.md` — quick-start `defineApp().use(createAuthPlugin(...)).use(createPaymentPlugin({ provider, webhookSecret }))`; the 3-way plug-and-play contract; `Idempotency-Key` recipe; webhook HMAC configuration; provider injection examples
|
||||
|
||||
## 5. Gates
|
||||
|
||||
- [x] 5.1 `cd /home/paul/web/genesis && bun run check`
|
||||
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-plugin-payment --force`
|
||||
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-plugin-payment` → 100/100/100/100
|
||||
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-plugin-payment` → exit 0
|
||||
- [x] 5.5 `bunx turbo build --filter=@suaveplan/api-plugin-payment --force`
|
||||
- [x] 5.6 `bun scripts/lint/check-test-runner-imports.ts`
|
||||
- [x] 5.7 Pre-archive 9-row checklist — banned-pattern grep + no `export *` + no direct vitest/bun:test imports + README + subpath count + bare-version check + missing .md scan
|
||||
|
||||
## 6. Archive
|
||||
|
||||
- [x] 6.1 Fold spec → `openspec/specs/api-plugin-payment/spec.md`
|
||||
- [x] 6.2 mv change → `openspec/archive/2026-05-19-add-api-plugin-payment/`
|
||||
- [x] 6.3 DO NOT refresh MANIFEST or STATUS (orchestrator consolidates)
|
||||
Reference in New Issue
Block a user