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

11 KiB

Capability Spec Delta: api-plugin-payment (add-api-plugin-payment)

Package: @suaveplan/api-plugin-payment Status: ACCEPTED

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"