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

260 lines
13 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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 `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 <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"`