Files
suaveplan/openspec/archive/2026-05-19-add-api-plugin-payment/specs/api-plugin-payment/spec.md
2026-05-27 23:29:53 +01:00

158 lines
11 KiB
Markdown

<!-- 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 -->