Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,111 @@
# @suaveplan/payment-provider-stripe - Universal Stripe adapter for @suaveplan/payment. Implements PaymentProvider against the Stripe REST API via the slim @suaveplan/payment-providers foundation: Bearer auth on every call, Idempotency-Key forwarding on every mutating call, exhaustive Stripe status/refund-status/refund-reason mapping, payment-method normalisation, every error raised as PaymentProviderError with provider: 'stripe' discriminant. No Node / browser / React deps; every HTTP call flows through the injectable FetchLike from the slim core.
Universal Stripe adapter for `@suaveplan/payment`. Implements
`PaymentProvider` against the Stripe REST API using the slim
`@suaveplan/payment-providers` foundation: Bearer auth on every call,
`Idempotency-Key` forwarding on every mutating call, exhaustive
Stripe → domain status / refund-status / refund-reason mapping,
payment-method normalisation, every error raised as
`PaymentProviderError` with `provider: "stripe"` discriminant.
This package is one of the five sibling adapters that re-landed
after `2026-05-17-slim-payment-providers` split the bundled
`@suaveplan/payment-providers` into a foundation-with-sibling-plugins
shape.
<!-- begin:auto-docs -->
## Documentation tree
- [stripe](./src/stripe/stripe.md) — `StripeProvider` is the Stripe adapter for `@suaveplan/payment`. It implements the `PaymentProvider` contract (six meth…
- [stripe](./src/stripe/stripe.md) — `StripeProvider` is the Stripe adapter for `@suaveplan/payment`. It implements the `PaymentProvider` contract (six meth…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/payment-provider-stripe \
@suaveplan/payment-providers \
@suaveplan/payment
```
## Quick start
```ts
import { PaymentEngine } from "@suaveplan/payment";
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
const provider = new StripeProvider({
apiKey: process.env.STRIPE_SECRET_KEY ?? "",
});
const engine = new PaymentEngine({ provider });
const intent = await engine.createIntent({
amount: { amount: 5000n, currency: "USD" },
captureMethod: "automatic",
idempotencyKey: crypto.randomUUID(),
});
```
## Compose with the foundation registry
```ts
import { ProviderRegistry } from "@suaveplan/payment-providers";
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
const registry = new ProviderRegistry();
registry.register(
new StripeProvider({ apiKey: process.env.STRIPE_SECRET_KEY ?? "" })
);
const stripe = registry.get("stripe"); // typed as PaymentProvider
```
## Features
- **`StripeProvider`** — implements `PaymentProvider` from
`@suaveplan/payment` (literal `name: "stripe"`, six lifecycle
methods).
- **Bearer authentication** on every request (read + write).
- **`Idempotency-Key` forwarding** — when the caller supplies
`idempotencyKey` on `CreatePaymentIntentInput` or `RefundInput`,
the provider forwards it verbatim to Stripe so retries collapse
into a single side effect.
- **Exhaustive status mappings** — seven Stripe payment-intent
statuses, three Stripe refund statuses, four Stripe refund reasons,
every unmapped value falls back to `failed` / `other`.
- **Payment-method normalisation** — `card` carries `brand` + `last4`;
any other Stripe `type` maps to `wallet`; absent payment method
maps to `undefined`.
- **Typed errors** — every failure is a `PaymentProviderError` from
the slim core with `provider: "stripe"` discriminant.
- **Universal tier** — zero Node, browser, React, or
platform-specific imports. Runs unchanged on Node, Bun, browsers,
and Web Workers.
- **Tree-shakeable** — `sideEffects: false` plus per-feature subpath
exports.
- **100% test coverage** (Istanbul) and **100% docs coverage**.
## Modules
- [`src/stripe/stripe.md`](./src/stripe/stripe.md) — `StripeProvider`
class, `StripeConfig`, `StripeIntent` / `StripeRefund` wire-format
interfaces.
## Related
- `@suaveplan/payment` — `PaymentEngine`, `WebhookVerifier`,
`PaymentProvider` interface, `PaymentIntent` / `Refund` / status
enums.
- `@suaveplan/payment-providers` — slim foundation with
`ProviderRegistry`, `httpRequest`, `composeAbortSignal`,
`PaymentProviderError`, shared config shapes.
- `@suaveplan/payment-provider-paypal`,
`@suaveplan/payment-provider-square`,
`@suaveplan/payment-provider-mollie`,
`@suaveplan/payment-provider-adyen` — sibling provider packages.
## License
MIT

View File

@@ -0,0 +1,57 @@
{
"name": "@suaveplan/payment-provider-stripe",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal Stripe adapter for @suaveplan/payment. Implements PaymentProvider against the Stripe REST API via the slim @suaveplan/payment-providers foundation: Bearer auth on every call, Idempotency-Key forwarding on every mutating call, exhaustive Stripe status/refund-status/refund-reason mapping, payment-method normalisation, every error raised as PaymentProviderError with provider: 'stripe' discriminant. No Node / browser / React deps; every HTTP call flows through the injectable FetchLike from the slim core.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./stripe/stripe": {
"import": { "types": "./dist/stripe/stripe.d.ts", "default": "./dist/stripe/stripe.js" },
"require": { "types": "./dist/stripe/stripe.d.ts", "default": "./dist/stripe/stripe.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/payment": "workspace:^",
"@suaveplan/payment-providers": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,6 @@
export { StripeProvider } from "./stripe/stripe.js";
export type {
StripeConfig,
StripeIntent,
StripeRefund,
} from "./stripe/stripe.types.js";

View File

@@ -0,0 +1,6 @@
export { StripeProvider } from "./stripe.js";
export type {
StripeConfig,
StripeIntent,
StripeRefund,
} from "./stripe.types.js";

View File

@@ -0,0 +1,201 @@
# StripeProvider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`StripeProvider` is the Stripe adapter for `@suaveplan/payment`. It
implements the `PaymentProvider` contract (six methods covering the
payment-intent + refund lifecycle) against the Stripe REST API, using
the slim `@suaveplan/payment-providers` foundation for every HTTP
call. The class adds nothing beyond what `PaymentProvider` requires:
no Stripe-specific surface bleeds into the consumer; no in-flight
state is held between calls. Provider construction takes the same
`ApiKeyConfig` shape every API-key-based sibling uses (`apiKey`,
optional `baseUrl`, optional `webhookSecret`, plus the
`ProviderCommonOptions` injectable seams — `fetch`, `timeoutMs`,
`signal`).
Every mutating Stripe call (`createIntent`, `refund`) forwards the
caller-supplied `idempotencyKey` as the standard
`Idempotency-Key` HTTP header so retries against Stripe collapse
into a single side effect. Bearer auth (`Authorization: Bearer
<apiKey>`) is sent on every call, including read-only `getIntent`,
so the provider works against either a Stripe live secret or test
secret without any further wiring.
Stripe lifecycle states are mapped exhaustively into the
domain enums from `@suaveplan/payment`: seven explicit Stripe
status strings collapse into the five user-visible `PaymentStatus`
values, an unknown status maps to `failed`, refund statuses collapse
to `succeeded` / `pending` / `failed`, refund reasons collapse to the
four `RefundReason` values with `other` as the fallback.
## Features
- Implements `PaymentProvider` from `@suaveplan/payment` — class
with literal `name: "stripe"`, all six methods, every return value
conforms to `PaymentIntent` / `Refund`.
- Routes every HTTP call through the slim-core `httpRequest` helper
— inherits per-call timeout, `AbortSignal` composition, typed
errors, empty-body handling, JSON parse-error handling.
- Bearer authentication on every request (read + write).
- `Idempotency-Key` header forwarded verbatim when the caller
supplies `idempotencyKey` on `CreatePaymentIntentInput` or
`RefundInput`. Omitted on read calls and on writes where the
caller did not supply a key.
- Exhaustive Stripe → domain status / refund-status / refund-reason
mappers; unknown values fall back to `failed` / `other`.
- Payment-method normalisation: Stripe `payment_method.type === "card"`
maps to `"card"` with `brand` and `last4`; any other type maps to
`"wallet"`; absent payment method maps to `undefined`.
- Every raised `PaymentProviderError` carries `provider: "stripe"` so
consumers running multiple providers can discriminate without
parsing messages.
- Universal — no `node:*`, no `bun:*`, no platform-specific
imports anywhere under `src/`. Runs unchanged on Node, Bun,
browsers, and Web Workers.
## Usage: Basic
```ts
import { PaymentEngine } from "@suaveplan/payment";
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
const provider = new StripeProvider({
apiKey: process.env.STRIPE_SECRET_KEY ?? "",
});
const engine = new PaymentEngine({ provider });
const intent = await engine.createIntent({
amount: { amount: 5000n, currency: "USD" },
captureMethod: "automatic",
});
```
## Usage: Advanced
Compose multiple providers via the slim-core `ProviderRegistry`,
inject a custom `FetchLike` for tracing/retries, scope every call to
a caller-supplied `AbortSignal`, and forward an `Idempotency-Key`
through the engine to collapse retried `createIntent` calls into a
single Stripe side effect:
```ts
import {
ProviderRegistry,
type FetchLike,
} from "@suaveplan/payment-providers";
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
const tracingFetch: FetchLike = async (url, init) => {
const start = performance.now();
try {
return await globalThis.fetch(url, init);
} finally {
// emit a span with (url, performance.now() - start)
}
};
const ac = new AbortController();
const provider = new StripeProvider({
apiKey: process.env.STRIPE_SECRET_KEY ?? "",
baseUrl: "https://api.stripe.com/v1",
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
fetch: tracingFetch,
timeoutMs: 10_000,
signal: ac.signal,
});
const registry = new ProviderRegistry();
registry.register(provider);
const intent = await registry.get("stripe").createIntent({
amount: { amount: 12_99n, currency: "USD" },
captureMethod: "manual",
idempotencyKey: crypto.randomUUID(),
});
```
## API Reference
### `class StripeProvider implements PaymentProvider`
```ts
new StripeProvider(config: StripeConfig)
```
- **`name`** — `"stripe"` (literal type, frozen on the class).
- **`createIntent(input: CreatePaymentIntentInput): Promise<PaymentIntent>`**
— `POST /payment_intents`. Maps `input.amount.amount` to Stripe's
minor-unit integer (`Number`), lowercases the currency, forwards
`customer` / `description` / `metadata` / `payment_method` when
supplied. Forwards `Idempotency-Key` from `input.idempotencyKey`.
- **`confirmIntent(intentId): Promise<PaymentIntent>`** — `POST
/payment_intents/:id/confirm`. No body, no `Idempotency-Key`.
- **`captureIntent(intentId): Promise<PaymentIntent>`** — `POST
/payment_intents/:id/capture`. No body, no `Idempotency-Key`.
- **`cancelIntent(intentId): Promise<PaymentIntent>`** — `POST
/payment_intents/:id/cancel`. No body, no `Idempotency-Key`.
- **`refund(input: RefundInput): Promise<Refund>`** — `POST
/refunds`. Forwards `amount` / `reason` / `metadata` when supplied.
Forwards `Idempotency-Key` from `input.idempotencyKey`.
- **`getIntent(intentId): Promise<PaymentIntent>`** — `GET
/payment_intents/:id`. No body, no `Idempotency-Key`.
### `type StripeConfig = ApiKeyConfig`
Construction shape — alias for the shared `ApiKeyConfig` from
`@suaveplan/payment-providers`. Re-exported by name so consumers can
pin construction shape locally:
- `apiKey: string` — Stripe secret key (live or test).
- `baseUrl?: string` — defaults to `https://api.stripe.com/v1`.
- `webhookSecret?: string` — surfaced for downstream
`WebhookVerifier` wiring; not used by the provider itself.
- `fetch?: FetchLike` — defaults to `globalThis.fetch`.
- `timeoutMs?: number` — per-call timeout (default 30 000 ms).
- `signal?: AbortSignal` — caller cancellation, composed with the
internal timeout signal.
### Error semantics
Every failure raised under `StripeProvider` is a `PaymentProviderError`
from the slim core. Possible codes:
- `PAYMENT_PROVIDER.HTTP_ERROR` — Stripe returned non-2xx; `httpStatus`
carries the status code, `bodyExcerpt` carries up to 256 chars of
the response.
- `PAYMENT_PROVIDER.TIMEOUT` — the per-call timeout fired before
Stripe responded.
- `PAYMENT_PROVIDER.PARSE_ERROR` — Stripe returned a malformed JSON
response.
Every raised error carries `provider: "stripe"` so consumers
discriminate without parsing messages.
## Implementation Notes
- The provider holds no in-flight Promise cache and no token cache —
Stripe uses static Bearer auth, so there's nothing to memoise.
Idempotency is delegated to Stripe via the `Idempotency-Key`
header; the provider just forwards the caller-supplied key.
- The status / refund-status / refund-reason mappings are exhaustive
and tested per-row. The fallback `failed` / `other` branches are
exercised by passing an unmapped Stripe enum value.
- `mapPaymentMethod` exists as a small dedicated helper so the
card vs. wallet vs. absent branches stay testable in isolation;
consumers can rely on `paymentMethod.type === "card"` always
carrying `brand` and `last4` when Stripe surfaced them.
- The `commonOptions` private accessor exists so every method
forwards `fetch`, `signal`, `timeoutMs`, and the `provider` field
in lockstep. Calling sites stay symmetric — adding a new
`PaymentProvider` method only needs to spread `this.commonOptions()`
into the `httpRequest` call.
- `package.json` declares `"sideEffects": false` and the only
runtime imports are `@suaveplan/payment` (types-only) and
`@suaveplan/payment-providers` (one named symbol: `httpRequest`).
The dep-graph test pins universal purity.

View File

@@ -0,0 +1,621 @@
import type { FetchLike } from "@suaveplan/payment-providers";
import {
PAYMENT_PROVIDER_CODES,
PaymentProviderError,
} from "@suaveplan/payment-providers";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { StripeProvider } from "./stripe.js";
import type { StripeIntent, StripeRefund } from "./stripe.types.js";
interface Recorded {
readonly url: string;
readonly init?: RequestInit;
}
function recorder(
handler: (url: string, init?: RequestInit) => Response | Promise<Response>
): { fetch: FetchLike; calls: Recorded[] } {
const calls: Recorded[] = [];
const fetchImpl: FetchLike = async (url, init) => {
calls.push({ url, init });
return handler(url, init);
};
return { fetch: fetchImpl, calls };
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
const BASE_INTENT: StripeIntent = {
id: "pi_123",
status: "requires_confirmation",
amount: 5000,
amount_received: 0,
amount_capturable: 5000,
currency: "usd",
capture_method: "manual",
customer: "cust_1",
description: "Test",
payment_method: {
id: "pm_1",
type: "card",
card: { brand: "visa", last4: "4242" },
},
metadata: { key: "val" },
created: 1_700_000_000,
};
const BASE_REFUND: StripeRefund = {
id: "ref_123",
payment_intent: "pi_123",
amount: 2000,
currency: "usd",
status: "succeeded",
reason: "requested_by_customer",
created: 1_700_000_100,
metadata: {},
};
function makeProvider(fetchImpl: FetchLike, apiKey = "sk_test_123") {
return new StripeProvider({
apiKey,
baseUrl: "https://stripe.mock/v1",
fetch: fetchImpl,
});
}
function headersOf(init: RequestInit | undefined): Record<string, string> {
return (init?.headers ?? {}) as Record<string, string>;
}
describe("StripeProvider — identity + base URL", () => {
it("exposes literal name 'stripe'", { timeout: 5000 }, () => {
const { fetch } = recorder(() => jsonResponse(BASE_INTENT));
const provider = makeProvider(fetch);
expect(provider.name).toBe("stripe");
});
it("defaults baseUrl to api.stripe.com/v1 when omitted", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder(() => jsonResponse(BASE_INTENT));
const provider = new StripeProvider({
apiKey: "sk_test_x",
fetch,
});
await provider.getIntent("pi_123");
expect(calls[0]?.url.startsWith("https://api.stripe.com/v1")).toBe(
true
);
});
});
describe("StripeProvider — URL + verb routing", () => {
it("createIntent POSTs to /payment_intents with Stripe-cased body fields", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder(() => jsonResponse(BASE_INTENT));
const provider = makeProvider(fetch);
await provider.createIntent({
amount: { amount: 5000n, currency: "USD" },
customerId: "cust_1",
description: "Test",
metadata: { k: "v" },
idempotencyKey: "idem_1",
captureMethod: "manual",
paymentMethodId: "pm_1",
});
const recorded = calls[0];
expect(recorded?.init?.method).toBe("POST");
expect(recorded?.url.endsWith("/payment_intents")).toBe(true);
const body = JSON.parse(String(recorded?.init?.body));
expect(body).toEqual({
amount: 5000,
currency: "usd",
capture_method: "manual",
customer: "cust_1",
description: "Test",
metadata: { k: "v" },
payment_method: "pm_1",
});
});
it("createIntent defaults capture_method to 'automatic' and omits optional fields when absent", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder(() => jsonResponse(BASE_INTENT));
const provider = makeProvider(fetch);
await provider.createIntent({
amount: { amount: 100n, currency: "EUR" },
});
const body = JSON.parse(String(calls[0]?.init?.body));
expect(body).toEqual({
amount: 100,
currency: "eur",
capture_method: "automatic",
});
});
it("confirmIntent POSTs to /payment_intents/:id/confirm", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder(() =>
jsonResponse({ ...BASE_INTENT, status: "requires_capture" })
);
const provider = makeProvider(fetch);
const result = await provider.confirmIntent("pi_123");
expect(calls[0]?.init?.method).toBe("POST");
expect(calls[0]?.url.endsWith("/payment_intents/pi_123/confirm")).toBe(
true
);
expect(result.status).toBe("authorized");
});
it("captureIntent POSTs to /payment_intents/:id/capture", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder(() =>
jsonResponse({
...BASE_INTENT,
status: "succeeded",
amount_received: 5000,
})
);
const provider = makeProvider(fetch);
const result = await provider.captureIntent("pi_123");
expect(calls[0]?.init?.method).toBe("POST");
expect(calls[0]?.url.endsWith("/payment_intents/pi_123/capture")).toBe(
true
);
expect(result.status).toBe("captured");
expect(result.capturedAmount.amount).toBe(5000n);
});
it("cancelIntent POSTs to /payment_intents/:id/cancel", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder(() =>
jsonResponse({ ...BASE_INTENT, status: "canceled" })
);
const provider = makeProvider(fetch);
const result = await provider.cancelIntent("pi_123");
expect(calls[0]?.init?.method).toBe("POST");
expect(calls[0]?.url.endsWith("/payment_intents/pi_123/cancel")).toBe(
true
);
expect(result.status).toBe("canceled");
});
it("refund POSTs to /refunds with snake_case body", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder(() => jsonResponse(BASE_REFUND));
const provider = makeProvider(fetch);
const result = await provider.refund({
paymentIntentId: "pi_123",
amount: { amount: 2000n, currency: "USD" },
reason: "requested_by_customer",
metadata: { tag: "x" },
});
expect(calls[0]?.init?.method).toBe("POST");
expect(calls[0]?.url.endsWith("/refunds")).toBe(true);
const body = JSON.parse(String(calls[0]?.init?.body));
expect(body).toEqual({
payment_intent: "pi_123",
amount: 2000,
reason: "requested_by_customer",
metadata: { tag: "x" },
});
expect(result.id).toBe("ref_123");
expect(result.status).toBe("succeeded");
expect(result.amount.amount).toBe(2000n);
});
it("refund omits optional fields when caller does not supply them", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder(() => jsonResponse(BASE_REFUND));
const provider = makeProvider(fetch);
await provider.refund({ paymentIntentId: "pi_only" });
const body = JSON.parse(String(calls[0]?.init?.body));
expect(body).toEqual({ payment_intent: "pi_only" });
});
it("getIntent GETs /payment_intents/:id", { timeout: 5000 }, async () => {
const { fetch, calls } = recorder(() => jsonResponse(BASE_INTENT));
const provider = makeProvider(fetch);
const result = await provider.getIntent("pi_123");
expect(calls[0]?.init?.method).toBe("GET");
expect(calls[0]?.url.endsWith("/payment_intents/pi_123")).toBe(true);
expect(result.id).toBe("pi_123");
});
it("surfaces non-ok responses as PaymentProviderError with provider 'stripe'", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() =>
jsonResponse({ message: "Server down" }, 500)
);
const provider = makeProvider(fetch);
await expect(provider.getIntent("pi_x")).rejects.toMatchObject({
code: PAYMENT_PROVIDER_CODES.HTTP_ERROR,
provider: "stripe",
httpStatus: 500,
});
});
});
describe("StripeProvider — auth + idempotency headers", () => {
it("includes Bearer Authorization header on every method", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder((url) => {
if (url.endsWith("/refunds")) {
return jsonResponse(BASE_REFUND);
}
return jsonResponse(BASE_INTENT);
});
const provider = makeProvider(fetch, "sk_live_42");
await provider.createIntent({
amount: { amount: 1n, currency: "USD" },
});
await provider.confirmIntent("pi_123");
await provider.captureIntent("pi_123");
await provider.cancelIntent("pi_123");
await provider.getIntent("pi_123");
await provider.refund({ paymentIntentId: "pi_123" });
for (const call of calls) {
expect(headersOf(call.init).Authorization).toBe(
"Bearer sk_live_42"
);
}
expect(calls).toHaveLength(6);
});
it("forwards Idempotency-Key header on createIntent when supplied", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder(() => jsonResponse(BASE_INTENT));
const provider = makeProvider(fetch);
await provider.createIntent({
amount: { amount: 1n, currency: "USD" },
idempotencyKey: "idem_1",
});
expect(headersOf(calls[0]?.init)["Idempotency-Key"]).toBe("idem_1");
});
it("omits Idempotency-Key header on createIntent when not supplied", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder(() => jsonResponse(BASE_INTENT));
const provider = makeProvider(fetch);
await provider.createIntent({
amount: { amount: 1n, currency: "USD" },
});
const headers = headersOf(calls[0]?.init);
expect("Idempotency-Key" in headers).toBe(false);
});
it("forwards Idempotency-Key header on refund when supplied", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder(() => jsonResponse(BASE_REFUND));
const provider = makeProvider(fetch);
await provider.refund({
paymentIntentId: "pi_123",
idempotencyKey: "ref_idem_1",
});
expect(headersOf(calls[0]?.init)["Idempotency-Key"]).toBe("ref_idem_1");
});
it("omits Idempotency-Key header on refund when not supplied", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder(() => jsonResponse(BASE_REFUND));
const provider = makeProvider(fetch);
await provider.refund({ paymentIntentId: "pi_123" });
const headers = headersOf(calls[0]?.init);
expect("Idempotency-Key" in headers).toBe(false);
});
});
describe("StripeProvider — status mapping", () => {
const cases: ReadonlyArray<readonly [string, string]> = [
["requires_payment_method", "created"],
["requires_confirmation", "created"],
["requires_action", "pending"],
["processing", "pending"],
["requires_capture", "authorized"],
["succeeded", "captured"],
["canceled", "canceled"],
];
for (const [stripeStatus, expected] of cases) {
it(`maps Stripe status '${stripeStatus}' to '${expected}'`, {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() =>
jsonResponse({ ...BASE_INTENT, status: stripeStatus })
);
const provider = makeProvider(fetch);
const result = await provider.getIntent("pi_test");
expect(result.status).toBe(expected);
});
}
it("maps unknown Stripe status to 'failed'", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() =>
jsonResponse({ ...BASE_INTENT, status: "weird_unhandled" })
);
const provider = makeProvider(fetch);
const result = await provider.getIntent("pi_test");
expect(result.status).toBe("failed");
});
});
describe("StripeProvider — refund status mapping", () => {
it("maps Stripe refund status 'succeeded' to 'succeeded'", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() =>
jsonResponse({ ...BASE_REFUND, status: "succeeded" })
);
const provider = makeProvider(fetch);
const result = await provider.refund({ paymentIntentId: "pi_123" });
expect(result.status).toBe("succeeded");
});
it("maps Stripe refund status 'pending' to 'pending'", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() =>
jsonResponse({ ...BASE_REFUND, status: "pending" })
);
const provider = makeProvider(fetch);
const result = await provider.refund({ paymentIntentId: "pi_123" });
expect(result.status).toBe("pending");
});
it("maps unknown Stripe refund status to 'failed'", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() =>
jsonResponse({ ...BASE_REFUND, status: "canceled" })
);
const provider = makeProvider(fetch);
const result = await provider.refund({ paymentIntentId: "pi_123" });
expect(result.status).toBe("failed");
});
});
describe("StripeProvider — refund reason mapping", () => {
it("maps Stripe refund reason 'duplicate' to 'duplicate'", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() =>
jsonResponse({ ...BASE_REFUND, reason: "duplicate" })
);
const provider = makeProvider(fetch);
const result = await provider.refund({ paymentIntentId: "pi_123" });
expect(result.reason).toBe("duplicate");
});
it("maps Stripe refund reason 'fraudulent' to 'fraudulent'", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() =>
jsonResponse({ ...BASE_REFUND, reason: "fraudulent" })
);
const provider = makeProvider(fetch);
const result = await provider.refund({ paymentIntentId: "pi_123" });
expect(result.reason).toBe("fraudulent");
});
it("maps Stripe refund reason 'requested_by_customer' to 'requested_by_customer'", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() =>
jsonResponse({ ...BASE_REFUND, reason: "requested_by_customer" })
);
const provider = makeProvider(fetch);
const result = await provider.refund({ paymentIntentId: "pi_123" });
expect(result.reason).toBe("requested_by_customer");
});
it("maps unknown Stripe refund reason to 'other'", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() =>
jsonResponse({ ...BASE_REFUND, reason: "something_else" })
);
const provider = makeProvider(fetch);
const result = await provider.refund({ paymentIntentId: "pi_123" });
expect(result.reason).toBe("other");
});
it("maps absent Stripe refund reason to 'other'", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() => {
const r = { ...BASE_REFUND };
delete (r as { reason?: string }).reason;
return jsonResponse(r);
});
const provider = makeProvider(fetch);
const result = await provider.refund({ paymentIntentId: "pi_123" });
expect(result.reason).toBe("other");
});
});
describe("StripeProvider — payment-method mapping", () => {
it("maps card payment_method with brand and last4", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() => jsonResponse(BASE_INTENT));
const provider = makeProvider(fetch);
const result = await provider.getIntent("pi_123");
expect(result.paymentMethod).toEqual({
id: "pm_1",
type: "card",
brand: "visa",
last4: "4242",
});
});
it("maps non-card payment_method type to 'wallet'", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() =>
jsonResponse({
...BASE_INTENT,
payment_method: { id: "pm_w", type: "us_bank_account" },
})
);
const provider = makeProvider(fetch);
const result = await provider.getIntent("pi_123");
expect(result.paymentMethod).toEqual({ id: "pm_w", type: "wallet" });
});
it("maps absent payment_method to undefined", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() => {
const i = { ...BASE_INTENT } as Partial<StripeIntent>;
delete i.payment_method;
return jsonResponse(i);
});
const provider = makeProvider(fetch);
const result = await provider.getIntent("pi_123");
expect(result.paymentMethod).toBeUndefined();
});
});
describe("StripeProvider — ProviderCommonOptions forwarding", () => {
it("forwards caller-supplied AbortSignal to httpRequest and propagates rejection", {
timeout: 5000,
}, async () => {
const external = new AbortController();
const { fetch } = recorder(
(_url, init) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () =>
reject(new DOMException("aborted", "AbortError"))
);
})
);
const provider = new StripeProvider({
apiKey: "sk_test_1",
baseUrl: "https://stripe.mock/v1",
fetch,
signal: external.signal,
});
const pending = provider.getIntent("pi_123");
external.abort();
await expect(pending).rejects.toBeInstanceOf(PaymentProviderError);
});
it("forwards caller-supplied timeoutMs to httpRequest", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(
(_url, init) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () =>
reject(new DOMException("aborted", "AbortError"))
);
})
);
const provider = new StripeProvider({
apiKey: "sk_test_1",
baseUrl: "https://stripe.mock/v1",
fetch,
timeoutMs: 20,
});
await expect(provider.getIntent("pi_123")).rejects.toMatchObject({
code: PAYMENT_PROVIDER_CODES.TIMEOUT,
provider: "stripe",
});
});
it("does not forward fetch when caller omits it (default-fetch path)", {
timeout: 5000,
}, async () => {
const original = globalThis.fetch;
const recorded: string[] = [];
Object.defineProperty(globalThis, "fetch", {
configurable: true,
value: async (url: string) => {
recorded.push(url);
return jsonResponse(BASE_INTENT);
},
});
try {
const provider = new StripeProvider({
apiKey: "sk_default_path",
baseUrl: "https://stripe.mock/v1",
});
const result = await provider.getIntent("pi_def");
expect(recorded).toEqual([
"https://stripe.mock/v1/payment_intents/pi_def",
]);
expect(result.id).toBe("pi_123");
} finally {
Object.defineProperty(globalThis, "fetch", {
configurable: true,
value: original,
});
}
});
});
describe("StripeProvider — mapping branch coverage", () => {
it("maps Stripe capture_method 'automatic' to PaymentIntent captureMethod 'automatic'", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() =>
jsonResponse({ ...BASE_INTENT, capture_method: "automatic" })
);
const provider = makeProvider(fetch);
const result = await provider.getIntent("pi_auto");
expect(result.captureMethod).toBe("automatic");
});
it("maps Stripe intent with absent customer/description/metadata to undefined fields", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() => {
const i = {
...BASE_INTENT,
} as Partial<StripeIntent> & { id: string };
delete i.customer;
delete i.description;
delete i.metadata;
return jsonResponse(i);
});
const provider = makeProvider(fetch);
const result = await provider.getIntent("pi_min");
expect(result.customerId).toBeUndefined();
expect(result.description).toBeUndefined();
expect(result.metadata).toBeUndefined();
});
it("maps Stripe refund with absent metadata to undefined metadata", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() => {
const r = {
...BASE_REFUND,
} as Partial<StripeRefund> & { id: string };
delete r.metadata;
return jsonResponse(r);
});
const provider = makeProvider(fetch);
const result = await provider.refund({ paymentIntentId: "pi_123" });
expect(result.metadata).toBeUndefined();
});
});

View File

@@ -0,0 +1,277 @@
import type {
CardBrand,
CreatePaymentIntentInput,
PaymentIntent,
PaymentMethod,
PaymentMethodType,
PaymentProvider,
PaymentStatus,
Refund,
RefundInput,
RefundReason,
RefundStatus,
} from "@suaveplan/payment";
import { type FetchLike, httpRequest } from "@suaveplan/payment-providers";
import type {
StripeConfig,
StripeIntent,
StripeRefund,
} from "./stripe.types.js";
const PROVIDER_NAME = "stripe" as const;
const DEFAULT_BASE_URL = "https://api.stripe.com/v1";
const STATUS_MAP: Readonly<Record<string, PaymentStatus>> = Object.freeze({
requires_payment_method: "created",
requires_confirmation: "created",
requires_action: "pending",
processing: "pending",
requires_capture: "authorized",
succeeded: "captured",
canceled: "canceled",
});
function mapStatus(stripeStatus: string): PaymentStatus {
return STATUS_MAP[stripeStatus] ?? "failed";
}
function mapRefundStatus(status: string): RefundStatus {
if (status === "succeeded") {
return "succeeded";
}
if (status === "pending") {
return "pending";
}
return "failed";
}
function mapRefundReason(reason: string | undefined): RefundReason {
if (reason === "duplicate") {
return "duplicate";
}
if (reason === "fraudulent") {
return "fraudulent";
}
if (reason === "requested_by_customer") {
return "requested_by_customer";
}
return "other";
}
function mapPaymentMethod(
pm: StripeIntent["payment_method"]
): PaymentMethod | undefined {
if (pm === undefined) {
return undefined;
}
const type: PaymentMethodType = pm.type === "card" ? "card" : "wallet";
if (pm.card !== undefined) {
return {
id: pm.id,
type,
brand: pm.card.brand as CardBrand,
last4: pm.card.last4,
};
}
return { id: pm.id, type };
}
function mapIntent(s: StripeIntent): PaymentIntent {
const currency = s.currency.toUpperCase();
const base = {
id: s.id,
status: mapStatus(s.status),
amount: { amount: BigInt(s.amount), currency },
capturedAmount: { amount: BigInt(s.amount_received), currency },
refundedAmount: { amount: 0n, currency },
captureMethod:
s.capture_method === "manual"
? ("manual" as const)
: ("automatic" as const),
refunds: [] as const,
createdAt: new Date(s.created * 1000).toISOString(),
updatedAt: new Date().toISOString(),
};
const paymentMethod = mapPaymentMethod(s.payment_method);
return {
...base,
...(s.customer !== undefined ? { customerId: s.customer } : {}),
...(s.description !== undefined ? { description: s.description } : {}),
...(paymentMethod !== undefined ? { paymentMethod } : {}),
...(s.metadata !== undefined ? { metadata: s.metadata } : {}),
};
}
function mapRefund(r: StripeRefund): Refund {
const base = {
id: r.id,
paymentIntentId: r.payment_intent,
amount: {
amount: BigInt(r.amount),
currency: r.currency.toUpperCase(),
},
status: mapRefundStatus(r.status),
reason: mapRefundReason(r.reason),
createdAt: new Date(r.created * 1000).toISOString(),
};
return r.metadata !== undefined ? { ...base, metadata: r.metadata } : base;
}
interface CommonOptions {
readonly provider: typeof PROVIDER_NAME;
readonly fetch?: FetchLike;
readonly signal?: AbortSignal;
readonly timeoutMs?: number;
}
/**
* Stripe payment provider implementing the `PaymentProvider` contract
* from `@suaveplan/payment`. Every HTTP call routes through the slim
* `@suaveplan/payment-providers` foundation (`httpRequest` +
* `FetchLike` + composed-`AbortSignal` semantics). Bearer auth is
* supplied on every request; the caller-supplied `Idempotency-Key`
* (when present on `CreatePaymentIntentInput` or `RefundInput`) is
* forwarded as the matching Stripe header on mutating calls.
*
* The provider holds no in-flight state: each method maps its input
* into Stripe's snake-cased wire format, issues a single
* `httpRequest`, and maps the response back to the domain
* `PaymentIntent` / `Refund` shapes. Errors raised by `httpRequest`
* (`HTTP_ERROR` / `TIMEOUT` / `PARSE_ERROR`) carry
* `provider: "stripe"` so consumers can discriminate Stripe failures
* from other registered providers without parsing messages.
*/
export class StripeProvider implements PaymentProvider {
public readonly name = PROVIDER_NAME;
private readonly config: StripeConfig;
private readonly baseUrl: string;
public constructor(config: StripeConfig) {
this.config = config;
this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
}
public async createIntent(
input: CreatePaymentIntentInput
): Promise<PaymentIntent> {
const body: Record<string, unknown> = {
amount: Number(input.amount.amount),
currency: input.amount.currency.toLowerCase(),
capture_method: input.captureMethod ?? "automatic",
};
if (input.customerId !== undefined) {
body["customer"] = input.customerId;
}
if (input.description !== undefined) {
body["description"] = input.description;
}
if (input.metadata !== undefined) {
body["metadata"] = input.metadata;
}
if (input.paymentMethodId !== undefined) {
body["payment_method"] = input.paymentMethodId;
}
const { data } = await httpRequest<StripeIntent>({
method: "POST",
url: `${this.baseUrl}/payment_intents`,
headers: this.headers(input.idempotencyKey),
body,
...this.commonOptions(),
});
return mapIntent(data);
}
public async confirmIntent(intentId: string): Promise<PaymentIntent> {
const { data } = await httpRequest<StripeIntent>({
method: "POST",
url: `${this.baseUrl}/payment_intents/${intentId}/confirm`,
headers: this.headers(),
...this.commonOptions(),
});
return mapIntent(data);
}
public async captureIntent(intentId: string): Promise<PaymentIntent> {
const { data } = await httpRequest<StripeIntent>({
method: "POST",
url: `${this.baseUrl}/payment_intents/${intentId}/capture`,
headers: this.headers(),
...this.commonOptions(),
});
return mapIntent(data);
}
public async cancelIntent(intentId: string): Promise<PaymentIntent> {
const { data } = await httpRequest<StripeIntent>({
method: "POST",
url: `${this.baseUrl}/payment_intents/${intentId}/cancel`,
headers: this.headers(),
...this.commonOptions(),
});
return mapIntent(data);
}
public async refund(input: RefundInput): Promise<Refund> {
const body: Record<string, unknown> = {
payment_intent: input.paymentIntentId,
};
if (input.amount !== undefined) {
body["amount"] = Number(input.amount.amount);
}
if (input.reason !== undefined) {
body["reason"] = input.reason;
}
if (input.metadata !== undefined) {
body["metadata"] = input.metadata;
}
const { data } = await httpRequest<StripeRefund>({
method: "POST",
url: `${this.baseUrl}/refunds`,
headers: this.headers(input.idempotencyKey),
body,
...this.commonOptions(),
});
return mapRefund(data);
}
public async getIntent(intentId: string): Promise<PaymentIntent> {
const { data } = await httpRequest<StripeIntent>({
method: "GET",
url: `${this.baseUrl}/payment_intents/${intentId}`,
headers: this.headers(),
...this.commonOptions(),
});
return mapIntent(data);
}
private headers(idempotencyKey?: string): Record<string, string> {
const h: Record<string, string> = {
Authorization: `Bearer ${this.config.apiKey}`,
};
if (idempotencyKey !== undefined) {
h["Idempotency-Key"] = idempotencyKey;
}
return h;
}
private commonOptions(): CommonOptions {
const opts: {
provider: typeof PROVIDER_NAME;
fetch?: FetchLike;
signal?: AbortSignal;
timeoutMs?: number;
} = { provider: PROVIDER_NAME };
if (this.config.fetch !== undefined) {
opts.fetch = this.config.fetch;
}
if (this.config.signal !== undefined) {
opts.signal = this.config.signal;
}
if (this.config.timeoutMs !== undefined) {
opts.timeoutMs = this.config.timeoutMs;
}
return opts;
}
}

View File

@@ -0,0 +1,52 @@
import type { ApiKeyConfig } from "@suaveplan/payment-providers";
/**
* Construction shape for {@link StripeProvider}. Re-uses the shared
* `ApiKeyConfig` from the slim foundation verbatim — the Stripe
* provider has no Stripe-specific configuration beyond the standard
* API-key + base-URL + webhook-secret + common HTTP seams. Re-exported
* as a named type so consumers can pin construction without reaching
* into `@suaveplan/payment-providers` for `ApiKeyConfig`.
*/
export type StripeConfig = ApiKeyConfig;
/**
* Wire shape of a Stripe payment-intent response, restricted to the
* fields {@link StripeProvider} reads. Stripe returns many more fields
* but the provider only maps these. Kept as an `interface` rather than
* a `type` so consumers extending the wire format for analytics
* purposes can merge declarations downstream.
*/
export interface StripeIntent {
readonly id: string;
readonly status: string;
readonly amount: number;
readonly amount_received: number;
readonly amount_capturable: number;
readonly currency: string;
readonly capture_method: string;
readonly customer?: string;
readonly description?: string;
readonly payment_method?: {
readonly id: string;
readonly type: string;
readonly card?: { readonly brand: string; readonly last4: string };
};
readonly metadata?: Record<string, string>;
readonly created: number;
}
/**
* Wire shape of a Stripe refund response, restricted to the fields
* {@link StripeProvider} reads.
*/
export interface StripeRefund {
readonly id: string;
readonly payment_intent: string;
readonly amount: number;
readonly currency: string;
readonly status: string;
readonly reason?: string;
readonly created: number;
readonly metadata?: Record<string, string>;
}

View File

@@ -0,0 +1,41 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const SRC_ROOT = join(__dirname, "..", "src");
function walkTypescript(dir: string): string[] {
const entries = readdirSync(dir);
const files: string[] = [];
for (const entry of entries) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
files.push(...walkTypescript(full));
} else if (
entry.endsWith(".ts") &&
!entry.endsWith(".test.ts") &&
!entry.endsWith(".spec.ts")
) {
files.push(full);
}
}
return files;
}
describe("universal dep graph", () => {
it("src contains no node/bun/fs/path/stream/net/http/crypto imports", {
timeout: 5000,
}, () => {
const banned =
/from\s+['"](node:|bun:|fs|path|stream|net|http|crypto)['"]/;
const offenders: string[] = [];
for (const file of walkTypescript(SRC_ROOT)) {
const text = readFileSync(file, "utf8");
if (banned.test(text)) {
offenders.push(file);
}
}
expect(offenders).toEqual([]);
});
});

View File

@@ -0,0 +1,5 @@
// Polyfills for the payment-provider-stripe test environment — none required.
// Every test uses an injectable FetchLike from the slim foundation, so the
// suite never reaches into a platform fetch. This file exists to mirror the
// sibling-package layout and to give vitest a stable setup-file path.
export {};

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,34 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/payment-provider-stripe",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/payment",
"@suaveplan/payment-providers",
"tslib",
],
},
},
});

View File

@@ -0,0 +1,27 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});