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 realPaymentProviderand a non-empty webhook secret - THEN the returned descriptor satisfies
PluginDescriptor<PaymentPluginConfig>withname === "payment"and at leastonInitdefined
Scenario: invalid options raise PAYMENT_PLUGIN.INVALID_INPUT
- WHEN
createPaymentPlugin({} as never)is called (missingprovider+webhookSecret) - THEN
PaymentPluginError(INVALID_INPUT)is thrown withcause 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:
- Resolve
appfromServiceRegistry; missingappSHALL throwPaymentPluginError(SERVICE_NOT_AVAILABLE)carryingcontext.missing = ["app"]. - Resolve the configured
PaymentProviderfrom the caller-suppliedoptions.provider(no default — caller injects). - Construct the
PaymentServicewrappingprovider+ thePaymentIntentStore(defaultMemoryPaymentIntentStore). - Register
paymentServicein theServiceRegistry(typed slot via module augmentation). - Register the 6 owner-scoped OpenAPI routes via
definePluginRoutesatroutePrefix(default/payments). Mount the webhook receiver (POST /payments/webhook) as a separate sub-router that reads the raw body before Zod parsing. - Log readiness via
ctx.logger.info.
Scenario: missing app raises SERVICE_NOT_AVAILABLE
- WHEN
onInitruns withoutappregistered in the hostServiceRegistry - THEN
PaymentPluginError(SERVICE_NOT_AVAILABLE)is thrown withcontext.missingincluding"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 withcontext.prefixset 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 /webhookis invoked with a tampered signature - THEN the response is HTTP 400 carrying
WEBHOOK_SIGNATURE_INVALIDANDpayments.webhook_signature_failures_totalis incremented AND the body is NOT JSON-parsed
Scenario: webhook with valid HMAC is accepted
- WHEN
POST /webhookis invoked with a signature computed from the configured secret overtimestamp.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)callsctx.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 byuserA - THEN
PaymentPluginError(NOT_FOUND)is thrown withstatusCode === 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 /intentsis called twice with identical body and identicalIdempotency-Key - THEN the second call returns the same
intent.idAND the provider was invoked exactly once
Scenario: different body under same key raises IDEMPOTENCY_CONFLICT
- WHEN
POST /intentsis called with the sameIdempotency-Keybut 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 /intentssucceeds against the in-memory MeterProvider - THEN
payments.intents_created_total{outcome:"success"}records one observation ANDpayments.intent_duration_msrecords one observation
Scenario: webhook signature failure increments dedicated counter
- WHEN
POST /webhookis called with an invalid signature - THEN
payments.webhook_signature_failures_totalrecords one observation ANDpayments.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.tsconstructs a realOpenAPIHono, registerscreateAuthPlugin+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 showsstatus:"captured"thenpartially_refundedafter a partial refund, AND user B'sGET /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"