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