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 aPluginDescriptorfrom@suaveplan/api-plugin. The descriptor implements the canonical 6-steponInit:- Resolve
app+openAPIRegistryfromServiceRegistry. - Resolve the caller-supplied
PaymentProviderfromoptions.provider(no default — caller injects). - Construct a
PaymentServicewrappingprovider+ thePaymentIntentStore(in-memory default) so ownership scoping flows through every route. - Register
paymentServicein the hostServiceRegistry(typed slot) exposing the full lifecycle. - Register 6 OpenAPI routes via
definePluginRoutes(mounted atroutePrefix, 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. - Log readiness via
ctx.logger.info.
- Resolve
PaymentPluginOptions(validated viaPaymentPluginOptionsSchema):{ 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 })returnsPaymentServiceexposing the full lifecycle:createIntent,confirmIntent,captureIntent,cancelIntent,refund,getIntent,listIntents. Every method is owner-scoped — it accepts aPrincipal.id(and optionaltenantId) and rejects access to foreign intents with a 404 (anti-enumeration). The service owns thePaymentIntentStorewrite-side so provider-side IDs map back to owner ids.
PaymentIntentStore
PaymentIntentStoreinterface — record-side persistence mapping a provider-issuedPaymentIntent.idback to itsownerId+ optionaltenantId.MemoryPaymentIntentStoreis 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 viaMoneySchema); response:PaymentIntentResponse. HonoursIdempotency-Keyheader.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. HonoursIdempotency-Keyheader.
Plus the unauthenticated webhook receiver:
POST /payments/webhook— verifies the provider-specific HMAC (X-Signature/X-Timestamp) againstoptions.webhookSecretBEFORE deserialising the body, replays throughEventBuswhenapi-plugin-eventsis mounted, otherwise applies updates via thepaymentServicedirectly.
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>viadefineCodes("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-upapi-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'sPaymentReconciliationdirectly.
Source mapping
This package is genesis-original with design intent drawn from:
- The genesis
@suaveplan/api-pluginPluginContext+ServiceRegistry. - The genesis
@suaveplan/api-openapidefinePluginRoutes+RouteBuilder. - The genesis
@suaveplan/api-plugin-authplug-and-play exemplar (3-step lifecycle + ContextVariableMap augmentation). - The legacy
enterprise/api-plugin-payment.mdenterprise plan (canonical source) — section 1 (Purpose), section 3 (Required improvements), section 4 (Feature-rich enhancements 1, 9). @suaveplan/payment—PaymentProviderinterface,PaymentIntent,Refund,VALID_TRANSITIONS,WebhookVerifier.@suaveplan/idempotency—IdempotencyGuard,MemoryIdempotencyStorefor theIdempotency-Keymiddleware.
| 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.