Files
2026-05-27 23:29:53 +01:00

9.7 KiB

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.