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

13 KiB

Capability Spec: payment-provider-stripe

Package: @suaveplan/payment-provider-stripe Status: SHIPPED

Every requirement below carries a **Verified by:** line.

History: introduced by 2026-05-17-add-payment-provider-stripe as the inaugural folded spec — the Stripe sibling adapter on the 2026-05-17-slim-payment-providers foundation.

Requirements

Requirement: StripeProvider implements PaymentProvider with literal name

The package SHALL export a StripeProvider class that implements the PaymentProvider interface from @suaveplan/payment. The class SHALL expose a readonly name field whose value is the string literal "stripe" so consumers (including ProviderRegistry) can discriminate it at both the type and value level. The constructor SHALL accept a StripeConfig extending ApiKeyConfig from @suaveplan/payment-providers — apiKey (Stripe secret key, required), optional baseUrl (defaults to https://api.stripe.com/v1), optional webhookSecret, plus every ProviderCommonOptions seam (fetch, timeoutMs, signal).

Scenario: name is the literal "stripe"

  • WHEN a StripeProvider instance is constructed with any config
  • THEN provider.name === "stripe" and the field is typed as the literal "stripe"

Scenario: defaults to api.stripe.com when baseUrl omitted

  • WHEN new StripeProvider({ apiKey: "sk_test_x" }) is constructed and any method is invoked
  • THEN the URL passed to the injected FetchLike starts with https://api.stripe.com/v1

Verified by: src/stripe/stripe.test.ts::"exposes literal name 'stripe'" AND src/stripe/stripe.test.ts::"defaults baseUrl to api.stripe.com/v1 when omitted"


Requirement: All six PaymentProvider methods route through slim-core httpRequest

The class SHALL implement every method on the PaymentProvider contract — createIntent, confirmIntent, captureIntent, cancelIntent, refund, getIntent — and every implementation SHALL delegate its single HTTP call to httpRequest from @suaveplan/payment-providers. The provider SHALL forward the caller-supplied fetch, signal, and timeoutMs from its constructor StripeConfig to every httpRequest call so external cancellation and timeout overrides flow through unchanged. Every httpRequest call SHALL set provider: "stripe" on its options so raised PaymentProviderErrors carry the Stripe discriminant.

Scenario: createIntent POSTs to /payment_intents

  • WHEN createIntent({ amount: { amount: 5000n, currency: "USD" }, captureMethod: "manual", customerId: "cust_1", description: "Test", metadata: { k: "v" }, paymentMethodId: "pm_1" }) is called
  • THEN the injected FetchLike receives one call with method: "POST", URL ending /payment_intents, JSON body containing the Stripe-cased fields (amount: 5000, currency: "usd", capture_method: "manual", customer: "cust_1", description: "Test", metadata: { k: "v" }, payment_method: "pm_1")

Scenario: confirmIntent POSTs to /payment_intents/:id/confirm

  • WHEN confirmIntent("pi_123") is called
  • THEN the injected FetchLike receives one call with URL ending /payment_intents/pi_123/confirm

Scenario: captureIntent POSTs to /payment_intents/:id/capture

  • WHEN captureIntent("pi_123") is called
  • THEN the injected FetchLike receives one call with URL ending /payment_intents/pi_123/capture

Scenario: cancelIntent POSTs to /payment_intents/:id/cancel

  • WHEN cancelIntent("pi_123") is called
  • THEN the injected FetchLike receives one call with URL ending /payment_intents/pi_123/cancel

Scenario: refund POSTs to /refunds

  • WHEN refund({ paymentIntentId: "pi_123", amount: { amount: 2000n, currency: "USD" }, reason: "requested_by_customer", metadata: {} }) is called
  • THEN the injected FetchLike receives one call with URL ending /refunds, body payment_intent: "pi_123", amount: 2000, reason: "requested_by_customer"

Scenario: getIntent GETs /payment_intents/:id

  • WHEN getIntent("pi_123") is called
  • THEN the injected FetchLike receives one call with method: "GET", URL ending /payment_intents/pi_123

Scenario: provider discriminant flows to raised errors

  • WHEN the injected FetchLike returns a 500 response on any method
  • THEN the rejection is a PaymentProviderError whose code === PAYMENT_PROVIDER.HTTP_ERROR and provider === "stripe"

Verified by: src/stripe/stripe.test.ts::"createIntent POSTs to /payment_intents with Stripe-cased body fields" AND src/stripe/stripe.test.ts::"confirmIntent POSTs to /payment_intents/:id/confirm" AND src/stripe/stripe.test.ts::"captureIntent POSTs to /payment_intents/:id/capture" AND src/stripe/stripe.test.ts::"cancelIntent POSTs to /payment_intents/:id/cancel" AND src/stripe/stripe.test.ts::"refund POSTs to /refunds with snake_case body" AND src/stripe/stripe.test.ts::"getIntent GETs /payment_intents/:id" AND src/stripe/stripe.test.ts::"surfaces non-ok responses as PaymentProviderError with provider 'stripe'"


Requirement: Bearer auth header on every request

Every HTTP call originated by StripeProvider SHALL include an Authorization: Bearer <apiKey> header where <apiKey> is the constructor-supplied secret. This header SHALL be present on read calls (getIntent) as well as mutating calls (createIntent/confirmIntent/captureIntent/cancelIntent/refund).

Scenario: Bearer header present on every method

  • WHEN any PaymentProvider method is invoked
  • THEN the Authorization header on the recorded fetch call equals Bearer <constructor-apiKey>

Verified by: src/stripe/stripe.test.ts::"includes Bearer Authorization header on every method"


Requirement: Idempotency-Key forwarded when caller supplies it

When the caller supplies an idempotencyKey on CreatePaymentIntentInput or RefundInput, StripeProvider SHALL forward it as an Idempotency-Key header on the matching POST request. When the caller omits idempotencyKey, no Idempotency-Key header SHALL be present on the request. Read-only requests (getIntent) SHALL never carry an Idempotency-Key header (the Stripe API ignores it on GET).

Scenario: createIntent forwards Idempotency-Key when supplied

  • WHEN createIntent({ ..., idempotencyKey: "idem_1" }) is called
  • THEN the recorded fetch call carries an Idempotency-Key: idem_1 header

Scenario: createIntent omits Idempotency-Key when not supplied

  • WHEN createIntent({ ... }) is called without idempotencyKey
  • THEN the recorded fetch call carries no Idempotency-Key header

Scenario: refund forwards Idempotency-Key when supplied

  • WHEN refund({ ..., idempotencyKey: "ref_idem_1" }) is called
  • THEN the recorded fetch call carries an Idempotency-Key: ref_idem_1 header

Scenario: refund omits Idempotency-Key when not supplied

  • WHEN refund({ paymentIntentId: "pi_123" }) is called without idempotencyKey
  • THEN the recorded fetch call carries no Idempotency-Key header

Verified by: src/stripe/stripe.test.ts::"forwards Idempotency-Key header on createIntent when supplied" AND src/stripe/stripe.test.ts::"omits Idempotency-Key header on createIntent when not supplied" AND src/stripe/stripe.test.ts::"forwards Idempotency-Key header on refund when supplied" AND src/stripe/stripe.test.ts::"omits Idempotency-Key header on refund when not supplied"


Requirement: Stripe payment-intent status mapping

StripeProvider SHALL map Stripe payment-intent status strings to PaymentStatus enum values using the following exhaustive table:

Stripe status PaymentStatus
requires_payment_method created
requires_confirmation created
requires_action pending
processing pending
requires_capture authorized
succeeded captured
canceled canceled
any other / unknown failed

Scenario: every documented mapping resolves correctly

  • WHEN the Stripe API returns a payment intent whose status is any of the eight rows above
  • THEN the mapped PaymentIntent.status equals the right-hand column

Verified by: src/stripe/stripe.test.ts::"maps Stripe status 'requires_payment_method' to 'created'" AND src/stripe/stripe.test.ts::"maps Stripe status 'requires_confirmation' to 'created'" AND src/stripe/stripe.test.ts::"maps Stripe status 'requires_action' to 'pending'" AND src/stripe/stripe.test.ts::"maps Stripe status 'processing' to 'pending'" AND src/stripe/stripe.test.ts::"maps Stripe status 'requires_capture' to 'authorized'" AND src/stripe/stripe.test.ts::"maps Stripe status 'succeeded' to 'captured'" AND src/stripe/stripe.test.ts::"maps Stripe status 'canceled' to 'canceled'" AND src/stripe/stripe.test.ts::"maps unknown Stripe status to 'failed'"


Requirement: Stripe refund status mapping

StripeProvider SHALL map Stripe refund status strings to RefundStatus enum values using the following exhaustive table:

Stripe refund status RefundStatus
succeeded succeeded
pending pending
any other / unknown failed

Scenario: every documented mapping resolves correctly

  • WHEN the Stripe API returns a refund whose status is any of the three rows above
  • THEN the mapped Refund.status equals the right-hand column

Verified by: src/stripe/stripe.test.ts::"maps Stripe refund status 'succeeded' to 'succeeded'" AND src/stripe/stripe.test.ts::"maps Stripe refund status 'pending' to 'pending'" AND src/stripe/stripe.test.ts::"maps unknown Stripe refund status to 'failed'"


Requirement: Stripe refund reason mapping

StripeProvider SHALL map Stripe refund reason strings to RefundReason enum values using the following exhaustive table:

Stripe reason RefundReason
duplicate duplicate
fraudulent fraudulent
requested_by_customer requested_by_customer
any other / unknown other

When the Stripe refund object omits reason entirely, the mapped Refund.reason SHALL be "other".

Scenario: every documented mapping resolves correctly

  • WHEN the Stripe API returns a refund whose reason is any of the listed values, an unknown value, or absent
  • THEN the mapped Refund.reason equals the right-hand column (or "other" when absent)

Verified by: src/stripe/stripe.test.ts::"maps Stripe refund reason 'duplicate' to 'duplicate'" AND src/stripe/stripe.test.ts::"maps Stripe refund reason 'fraudulent' to 'fraudulent'" AND src/stripe/stripe.test.ts::"maps Stripe refund reason 'requested_by_customer' to 'requested_by_customer'" AND src/stripe/stripe.test.ts::"maps unknown Stripe refund reason to 'other'" AND src/stripe/stripe.test.ts::"maps absent Stripe refund reason to 'other'"


Requirement: Payment-method mapping (card → wallet → absent)

When the Stripe payment intent carries a payment_method object, StripeProvider SHALL surface it on the returned PaymentIntent as a PaymentMethod with id, type ("card" if Stripe reports a card type, otherwise "wallet"), brand (from payment_method.card.brand when present), and last4 (from payment_method.card.last4 when present). When the Stripe payment intent omits payment_method, the mapped PaymentIntent.paymentMethod SHALL be undefined.

Scenario: card payment method is mapped to type 'card' with brand and last4

  • WHEN the Stripe API returns a payment intent with payment_method: { id: "pm_1", type: "card", card: { brand: "visa", last4: "4242" } }
  • THEN the mapped PaymentIntent.paymentMethod equals { id: "pm_1", type: "card", brand: "visa", last4: "4242" }

Scenario: non-card payment method maps to type 'wallet'

  • WHEN the Stripe API returns a payment intent with payment_method: { id: "pm_w", type: "us_bank_account" }
  • THEN the mapped PaymentIntent.paymentMethod.type equals "wallet"

Scenario: absent payment method maps to undefined

  • WHEN the Stripe API returns a payment intent without payment_method
  • THEN the mapped PaymentIntent.paymentMethod is undefined

Verified by: src/stripe/stripe.test.ts::"maps card payment_method with brand and last4" AND src/stripe/stripe.test.ts::"maps non-card payment_method type to 'wallet'" AND src/stripe/stripe.test.ts::"maps absent payment_method to undefined"


Requirement: Universal-tier purity

The package SHALL ship as a universal-tier package — no node:*, bun:*, fs, path, stream, net, http, or bare crypto imports in any file under src/. The package SHALL run unchanged under Node ≥ 18, Bun, modern browsers, and Web Workers. package.json SHALL declare "sideEffects": false. Every HTTP call SHALL go through the slim-core httpRequest helper (and therefore through the injectable FetchLike) so the package never reaches into a runtime-specific HTTP stack.

Scenario: no platform imports in src/

  • WHEN the dep-graph test walks every .ts file under src/ and matches the banned-import regex
  • THEN the resulting offender array is empty

Verified by: tests/dep-graph.test.ts::"src contains no node/bun/fs/path/stream/net/http/crypto imports"