# Capability Spec Delta: payment-provider-stripe (add-payment-provider-stripe) **Package**: `@suaveplan/payment-provider-stripe` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at > authoring time. This is the inaugural delta for the > `payment-provider-stripe` capability — every block is `## ADDED > Requirements`. ## ADDED 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 `PaymentProviderError`s 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 ` header where `` 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 ` **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"`