@suluk/payments 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +28 -0
- package/src/connector.ts +92 -0
- package/src/connectors/stripe.ts +183 -0
- package/src/errors.ts +24 -0
- package/src/index.ts +31 -0
- package/src/mock.ts +60 -0
- package/src/pricing.ts +167 -0
- package/src/stripe-transport.ts +44 -0
- package/src/stripe-webhook.ts +98 -0
- package/src/types.ts +216 -0
- package/test/payments.test.ts +87 -0
- package/test/pricing-webhook.test.ts +59 -0
- package/test/stripe-connector.test.ts +157 -0
- package/tsconfig.json +1 -0
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@suluk/payments",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Provider-agnostic payments — a Workers-native TypeScript reimplementation of the Hyperswitch Prism connector interface. ONE unified request schema (authorize/capture/void/refund/sync + optional customer/tokenize/recurring/webhook); switch processor by config, not code. Prism's native FFI core can't run on the edge, so we adopt its interface + integer-exact status semantics and implement over fetch — zero native deps, light, swappable. The seam that supersedes @suluk/stripe. INTERFACE-first (C048); real connectors + the billing rewire follow. CANDIDATE tooling.",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"license": "Apache-2.0",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/MahmoodKhalil57/suluk.git",
|
|
12
|
+
"directory": "tooling/ts/packages/payments"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/MahmoodKhalil57/suluk#readme",
|
|
15
|
+
"bugs": "https://github.com/MahmoodKhalil57/suluk/issues",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "src/index.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.ts"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/bun": "latest"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "bun test",
|
|
26
|
+
"typecheck": "tsc --noEmit -p ."
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/connector.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agnostic seam (C048) — the `PaymentConnector` interface every processor implements, plus the Prism-style
|
|
3
|
+
* config-selects-the-connector model: you pass ONE `connectorConfig` naming the processor + its credentials, and
|
|
4
|
+
* `paymentClient` returns a connector bound to it. Switching Stripe→Adyen is a config change, not a code change — the
|
|
5
|
+
* whole point. Connectors are FETCH-based (Workers-native); they translate the unified schema to a processor's REST API
|
|
6
|
+
* and its response back. This file is the INTERFACE; concrete connectors (stripe, adyen, …) are separate modules.
|
|
7
|
+
*/
|
|
8
|
+
import type {
|
|
9
|
+
AuthorizeRequest, PaymentResponse, CaptureRequest, VoidRequest, RefundRequest, RefundResponse, SyncRequest, Secret,
|
|
10
|
+
ClientSession, CreatePaymentSessionRequest, CreateSetupSessionRequest,
|
|
11
|
+
} from "./types";
|
|
12
|
+
import { IntegrationError } from "./errors";
|
|
13
|
+
|
|
14
|
+
/** Per-request HTTP tuning + the mockable transport seam (a Worker passes nothing → global fetch; a test passes a mock). */
|
|
15
|
+
export interface HttpOptions {
|
|
16
|
+
fetch?: typeof fetch;
|
|
17
|
+
totalTimeoutMs?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A payment processor behind the unified schema. The CORE flows (authorize/capture/void/refund/sync) are required; the
|
|
22
|
+
* advanced surfaces (customer, tokenize/vault, recurring, webhook) are OPTIONAL — a connector declares them as it gains
|
|
23
|
+
* coverage, and the caller feature-detects. A soft decline is returned as `status: FAILURE`, never thrown.
|
|
24
|
+
*/
|
|
25
|
+
export interface PaymentConnector {
|
|
26
|
+
/** the processor id, e.g. "stripe". */
|
|
27
|
+
readonly name: string;
|
|
28
|
+
authorize(req: AuthorizeRequest): Promise<PaymentResponse>;
|
|
29
|
+
capture(req: CaptureRequest): Promise<PaymentResponse>;
|
|
30
|
+
void(req: VoidRequest): Promise<PaymentResponse>;
|
|
31
|
+
refund(req: RefundRequest): Promise<RefundResponse>;
|
|
32
|
+
sync(req: SyncRequest): Promise<PaymentResponse>;
|
|
33
|
+
|
|
34
|
+
// ── optional advanced surfaces (mirroring Prism's extra clients); added per-connector as supported ──
|
|
35
|
+
/** create a processor customer (returns its id). */
|
|
36
|
+
createCustomer?(req: { email?: string; metadata?: Record<string, string> }): Promise<{ customerId: string }>;
|
|
37
|
+
/** vault an instrument → a reusable token (the app/processor vault; the library stores nothing). */
|
|
38
|
+
tokenize?(req: { customerId?: string; paymentMethod: AuthorizeRequest["paymentMethod"] }): Promise<{ token: Secret }>;
|
|
39
|
+
/** set up an off-session mandate for recurring charges. */
|
|
40
|
+
recurringSetup?(req: { customerId: string; paymentMethod: AuthorizeRequest["paymentMethod"] }): Promise<{ mandateId: string }>;
|
|
41
|
+
/** charge an established recurring mandate off-session. */
|
|
42
|
+
recurringCharge?(req: { mandateId: string; amount: AuthorizeRequest["amount"]; merchantTransactionId: string }): Promise<PaymentResponse>;
|
|
43
|
+
/** revoke a recurring mandate. */
|
|
44
|
+
recurringRevoke?(req: { mandateId: string }): Promise<void>;
|
|
45
|
+
/** verify + normalize a processor webhook into a unified event. */
|
|
46
|
+
handleWebhook?(raw: string, headers: Record<string, string>): Promise<WebhookEvent>;
|
|
47
|
+
|
|
48
|
+
// ── the client-token surface (Prism's MerchantAuthenticationClient) — browser-confirmable sessions ──
|
|
49
|
+
/** Create a browser-confirmable PAYMENT session (Stripe PaymentIntent client_secret) — the Payment-Element / one-click
|
|
50
|
+
* path. The browser confirms with the processor SDK; crediting happens on the webhook, not here. */
|
|
51
|
+
createPaymentSession?(req: CreatePaymentSessionRequest): Promise<ClientSession>;
|
|
52
|
+
/** Create a browser-confirmable SETUP session (Stripe SetupIntent client_secret) — vault a card without charging. */
|
|
53
|
+
createSetupSession?(req: CreateSetupSessionRequest): Promise<ClientSession>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A normalized webhook event (the unified shape a connector's handleWebhook produces). */
|
|
57
|
+
export interface WebhookEvent {
|
|
58
|
+
type: string;
|
|
59
|
+
connectorTransactionId?: string;
|
|
60
|
+
status?: number;
|
|
61
|
+
raw: unknown;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A connector's typed credentials (per processor). `Secret`-wrapped so keys aren't logged. */
|
|
65
|
+
export type ConnectorAuth = Record<string, Secret | undefined>;
|
|
66
|
+
|
|
67
|
+
/** The Prism-shaped config: exactly ONE processor named under `connectorConfig`. */
|
|
68
|
+
export interface ConnectorConfig {
|
|
69
|
+
connectorConfig: Record<string, ConnectorAuth>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Builds a connector from its typed auth + http options — what each processor module exports. */
|
|
73
|
+
export type ConnectorFactory = (auth: ConnectorAuth, http?: HttpOptions) => PaymentConnector;
|
|
74
|
+
|
|
75
|
+
/** The connector registry: processor id → factory. The app composes it from the connector modules it ships. */
|
|
76
|
+
export type ConnectorRegistry = Record<string, ConnectorFactory>;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The Prism-style entry point: read the single processor named in `config.connectorConfig`, look up its factory in the
|
|
80
|
+
* registry, and return a bound {@link PaymentConnector}. Throws {@link IntegrationError} on a config that names zero, more
|
|
81
|
+
* than one, or an unregistered processor — a request-phase bug, surfaced before any money moves.
|
|
82
|
+
*/
|
|
83
|
+
export function paymentClient(config: ConnectorConfig, registry: ConnectorRegistry, http?: HttpOptions): PaymentConnector {
|
|
84
|
+
const names = Object.keys(config.connectorConfig ?? {});
|
|
85
|
+
if (names.length !== 1) {
|
|
86
|
+
throw new IntegrationError("INVALID_CONFIGURATION", `connectorConfig must name exactly one processor (got ${names.length}: [${names.join(", ")}])`);
|
|
87
|
+
}
|
|
88
|
+
const name = names[0];
|
|
89
|
+
const factory = registry[name];
|
|
90
|
+
if (!factory) throw new IntegrationError("INVALID_CONFIGURATION", `no connector registered for "${name}" (registry has: [${Object.keys(registry).join(", ")}])`);
|
|
91
|
+
return factory(config.connectorConfig[name], http);
|
|
92
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in Stripe connector (C048) — the first real backend for the agnostic {@link PaymentConnector}. It translates
|
|
3
|
+
* the unified schema to Stripe's REST API (`fetch` over `x-www-form-urlencoded`, Workers-native — no `stripe` SDK, no
|
|
4
|
+
* native deps) and maps Stripe's response back to the integer-exact unified {@link PaymentStatus}/{@link RefundStatus}.
|
|
5
|
+
* The status MAPPING is the load-bearing, parity-critical part (a wrong mapping silently mis-reports a charge), so every
|
|
6
|
+
* arm is witnessed. Soft declines come back IN-BAND as `FAILURE`/`AUTHENTICATION_PENDING`; only transport/unexpected
|
|
7
|
+
* errors throw (Prism's contract).
|
|
8
|
+
*/
|
|
9
|
+
import type { ConnectorAuth, ConnectorFactory, HttpOptions, PaymentConnector } from "../connector";
|
|
10
|
+
import { ConnectorError, IntegrationError, NetworkError } from "../errors";
|
|
11
|
+
import { PaymentStatus, RefundStatus, CaptureMethod, type PaymentResponse } from "../types";
|
|
12
|
+
import { stripePost, stripeGet, toForm } from "../stripe-transport";
|
|
13
|
+
|
|
14
|
+
interface StripePi {
|
|
15
|
+
id?: string;
|
|
16
|
+
status?: string;
|
|
17
|
+
amount?: number;
|
|
18
|
+
currency?: string;
|
|
19
|
+
client_secret?: string;
|
|
20
|
+
next_action?: { redirect_to_url?: { url?: string } };
|
|
21
|
+
last_payment_error?: { code?: string; message?: string; decline_code?: string };
|
|
22
|
+
}
|
|
23
|
+
interface StripeErr {
|
|
24
|
+
error?: { type?: string; code?: string; message?: string; decline_code?: string; payment_intent?: StripePi };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Stripe PaymentIntent.status → the unified PaymentStatus (the parity table). */
|
|
28
|
+
function mapPiStatus(s: string | undefined): PaymentStatus {
|
|
29
|
+
switch (s) {
|
|
30
|
+
case "succeeded": return PaymentStatus.CHARGED;
|
|
31
|
+
case "requires_capture": return PaymentStatus.AUTHORIZED;
|
|
32
|
+
case "requires_action":
|
|
33
|
+
case "requires_confirmation": return PaymentStatus.AUTHENTICATION_PENDING;
|
|
34
|
+
case "processing": return PaymentStatus.PENDING;
|
|
35
|
+
case "canceled": return PaymentStatus.VOIDED;
|
|
36
|
+
case "requires_payment_method": return PaymentStatus.FAILURE; // the (initial) attempt didn't succeed
|
|
37
|
+
default: return PaymentStatus.UNRESOLVED;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Stripe Refund.status → the unified RefundStatus. */
|
|
42
|
+
function mapRefundStatus(s: string | undefined): RefundStatus {
|
|
43
|
+
switch (s) {
|
|
44
|
+
case "succeeded": return RefundStatus.REFUND_SUCCESS;
|
|
45
|
+
case "pending":
|
|
46
|
+
case "requires_action": return RefundStatus.REFUND_PENDING;
|
|
47
|
+
case "failed":
|
|
48
|
+
case "canceled": return RefundStatus.REFUND_FAILURE;
|
|
49
|
+
default: return RefundStatus.UNSPECIFIED;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Build the unified PaymentResponse from a Stripe PI (the success/redirect arm). */
|
|
54
|
+
function piToResponse(pi: StripePi): PaymentResponse {
|
|
55
|
+
const status = mapPiStatus(pi.status);
|
|
56
|
+
const res: PaymentResponse = { status, connectorTransactionId: pi.id };
|
|
57
|
+
if (status === PaymentStatus.AUTHENTICATION_PENDING && pi.next_action?.redirect_to_url?.url) {
|
|
58
|
+
res.redirectionData = { url: pi.next_action.redirect_to_url.url, method: "GET" };
|
|
59
|
+
}
|
|
60
|
+
if (pi.amount != null && pi.currency) res.amount = { minorAmount: pi.amount, currency: pi.currency.toUpperCase() };
|
|
61
|
+
return res;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const stripeConnector: ConnectorFactory = (auth: ConnectorAuth, http?: HttpOptions): PaymentConnector => {
|
|
65
|
+
const secret = auth.apiKey?.value;
|
|
66
|
+
if (!secret) throw new IntegrationError("INVALID_CONFIGURATION", "stripe connector needs { apiKey: { value } }");
|
|
67
|
+
const doFetch = http?.fetch ?? fetch;
|
|
68
|
+
|
|
69
|
+
const cfg = { secretKey: secret, fetch: doFetch };
|
|
70
|
+
async function call(path: string, form: URLSearchParams | null, idempotencyKey?: string): Promise<{ ok: boolean; status: number; json: StripePi & StripeErr }> {
|
|
71
|
+
let res: Response;
|
|
72
|
+
try {
|
|
73
|
+
res = form ? await stripePost(cfg, path, form, idempotencyKey) : await stripeGet(cfg, path);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
throw new NetworkError("CONNECT_TIMEOUT", e instanceof Error ? e.message : String(e));
|
|
76
|
+
}
|
|
77
|
+
const json = (await res.json().catch(() => ({}))) as StripePi & StripeErr;
|
|
78
|
+
return { ok: res.ok, status: res.status, json };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
name: "stripe",
|
|
83
|
+
|
|
84
|
+
async authorize(req) {
|
|
85
|
+
const form: Record<string, unknown> = {
|
|
86
|
+
amount: req.amount.minorAmount,
|
|
87
|
+
currency: req.amount.currency.toLowerCase(),
|
|
88
|
+
capture_method: req.captureMethod === CaptureMethod.MANUAL ? "manual" : "automatic",
|
|
89
|
+
confirm: true,
|
|
90
|
+
customer: req.customerId,
|
|
91
|
+
off_session: req.offSession ? true : undefined,
|
|
92
|
+
setup_future_usage: req.setupFutureUsage ? "off_session" : undefined,
|
|
93
|
+
return_url: req.returnUrl,
|
|
94
|
+
metadata: { merchantTransactionId: req.merchantTransactionId, ...req.metadata },
|
|
95
|
+
};
|
|
96
|
+
const token = req.paymentMethod.token?.value; // a saved/vaulted payment method id (Secret)
|
|
97
|
+
if (token) {
|
|
98
|
+
form.payment_method = token; // a saved/vaulted payment method id
|
|
99
|
+
} else if (req.paymentMethod.card) {
|
|
100
|
+
const c = req.paymentMethod.card;
|
|
101
|
+
form.payment_method_data = { type: "card", card: { number: c.cardNumber.value, exp_month: c.cardExpMonth.value, exp_year: c.cardExpYear.value, cvc: c.cardCvc.value } };
|
|
102
|
+
} else {
|
|
103
|
+
throw new IntegrationError("MISSING_REQUIRED_FIELD", "authorize needs paymentMethod.card or paymentMethod.token");
|
|
104
|
+
}
|
|
105
|
+
// the merchant txn id is the idempotency key — a retry of the SAME logical charge never double-charges.
|
|
106
|
+
const { ok, status, json } = await call("payment_intents", toForm(form), `auth:${req.merchantTransactionId}`);
|
|
107
|
+
if (ok) return piToResponse(json);
|
|
108
|
+
// error arm: an off-session 3DS decline carries the PI (→ AUTHENTICATION_PENDING); a card decline is an in-band
|
|
109
|
+
// FAILURE (Stripe returns HTTP 402 / `type: card_error` for these — an EXPECTED card outcome, not a transport
|
|
110
|
+
// failure); anything else (4xx config error, 5xx, network) THROWS as a ConnectorError.
|
|
111
|
+
const err = json.error;
|
|
112
|
+
if (err?.code === "authentication_required" && err.payment_intent) {
|
|
113
|
+
return { status: PaymentStatus.AUTHENTICATION_PENDING, connectorTransactionId: err.payment_intent.id, error: { code: err.code, message: err.message } };
|
|
114
|
+
}
|
|
115
|
+
if (status === 402 || err?.type === "card_error") {
|
|
116
|
+
return { status: PaymentStatus.FAILURE, connectorTransactionId: err?.payment_intent?.id, error: { code: err?.decline_code ?? err?.code, message: err?.message } };
|
|
117
|
+
}
|
|
118
|
+
throw new ConnectorError(err?.code ?? "stripe_error", err?.message ?? `Stripe authorize failed (HTTP ${status})`);
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
async capture(req) {
|
|
122
|
+
const { ok, json } = await call(`payment_intents/${req.connectorTransactionId}/capture`, toForm({ amount_to_capture: req.amountToCapture.minorAmount }));
|
|
123
|
+
if (!ok) return { status: PaymentStatus.CAPTURE_FAILED, connectorTransactionId: req.connectorTransactionId, error: { code: json.error?.code, message: json.error?.message } };
|
|
124
|
+
return piToResponse(json);
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async void(req) {
|
|
128
|
+
const { ok, json } = await call(`payment_intents/${req.connectorTransactionId}/cancel`, toForm({ cancellation_reason: "requested_by_customer" }));
|
|
129
|
+
if (!ok) return { status: PaymentStatus.VOID_FAILED, connectorTransactionId: req.connectorTransactionId, error: { code: json.error?.code, message: json.error?.message } };
|
|
130
|
+
return piToResponse(json);
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
async refund(req) {
|
|
134
|
+
const { ok, json } = await call("refunds", toForm({ payment_intent: req.connectorTransactionId, amount: req.refundAmount.minorAmount }), `refund:${req.merchantRefundId}`);
|
|
135
|
+
const r = json as { id?: string; status?: string } & StripeErr;
|
|
136
|
+
if (!ok) return { status: RefundStatus.REFUND_FAILURE, error: { code: r.error?.code, message: r.error?.message } };
|
|
137
|
+
return { status: mapRefundStatus(r.status), connectorRefundId: r.id };
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
async sync(req) {
|
|
141
|
+
const { ok, json } = await call(`payment_intents/${req.connectorTransactionId}`, null);
|
|
142
|
+
if (!ok) return { status: PaymentStatus.UNRESOLVED, connectorTransactionId: req.connectorTransactionId, error: { code: json.error?.code, message: json.error?.message } };
|
|
143
|
+
return piToResponse(json);
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
async createCustomer(req) {
|
|
147
|
+
const { ok, json } = await call("customers", toForm({ email: req.email, metadata: req.metadata }));
|
|
148
|
+
const c = json as { id?: string } & StripeErr;
|
|
149
|
+
if (!ok || !c.id) throw new ConnectorError(c.error?.code ?? "stripe_error", c.error?.message ?? "Stripe customer create failed");
|
|
150
|
+
return { customerId: c.id };
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
async createPaymentSession(req) {
|
|
154
|
+
const form: Record<string, unknown> = {
|
|
155
|
+
amount: req.amount.minorAmount,
|
|
156
|
+
currency: req.amount.currency.toLowerCase(),
|
|
157
|
+
customer: req.customerId,
|
|
158
|
+
metadata: req.metadata,
|
|
159
|
+
...(req.captureMethod === CaptureMethod.MANUAL ? { capture_method: "manual" } : {}),
|
|
160
|
+
...(req.setupFutureUsage ? { setup_future_usage: "off_session" } : {}),
|
|
161
|
+
};
|
|
162
|
+
const token = req.paymentMethod?.value;
|
|
163
|
+
// a saved token → a one-click charge pinned to it; otherwise the browser collects the card (Payment Element).
|
|
164
|
+
if (token) { form.payment_method = token; form.payment_method_types = ["card"]; }
|
|
165
|
+
else form.automatic_payment_methods = { enabled: true };
|
|
166
|
+
const { ok, status, json } = await call("payment_intents", toForm(form));
|
|
167
|
+
if (!ok || json.error || !json.client_secret) throw new ConnectorError(json.error?.code ?? "stripe_error", json.error?.message ?? `Stripe payment session failed (HTTP ${status})`);
|
|
168
|
+
return { clientSecret: json.client_secret, connectorTransactionId: json.id, customerId: req.customerId };
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
async createSetupSession(req) {
|
|
172
|
+
const { ok, status, json } = await call("setup_intents", toForm({
|
|
173
|
+
customer: req.customerId,
|
|
174
|
+
automatic_payment_methods: { enabled: true },
|
|
175
|
+
usage: "off_session",
|
|
176
|
+
metadata: req.metadata,
|
|
177
|
+
}));
|
|
178
|
+
if (!ok || json.error || !json.client_secret) throw new ConnectorError(json.error?.code ?? "stripe_error", json.error?.message ?? `Stripe setup session failed (HTTP ${status})`);
|
|
179
|
+
return { clientSecret: json.client_secret, connectorTransactionId: json.id, customerId: req.customerId };
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The error taxonomy (C048) — mirrors Prism's three hard-failure classes. A SOFT decline is NOT one of these: it comes
|
|
3
|
+
* back in-band as `status: FAILURE` on the response. These are thrown only for request-phase config/validation problems
|
|
4
|
+
* (`IntegrationError`), unexpected processor responses (`ConnectorError`), and transport failures (`NetworkError`) — so a
|
|
5
|
+
* caller can distinguish "the card was declined" (in-band, expected) from "the integration is broken" (thrown).
|
|
6
|
+
*/
|
|
7
|
+
export class PaymentLibError extends Error {
|
|
8
|
+
constructor(
|
|
9
|
+
public readonly errorCode: string,
|
|
10
|
+
message: string,
|
|
11
|
+
) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = new.target.name;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Request-phase: bad/missing config, a missing required field, a serialization failure. The caller's bug to fix. */
|
|
18
|
+
export class IntegrationError extends PaymentLibError {}
|
|
19
|
+
|
|
20
|
+
/** Response-phase: the processor returned an unexpected shape the connector couldn't transform. */
|
|
21
|
+
export class ConnectorError extends PaymentLibError {}
|
|
22
|
+
|
|
23
|
+
/** Transport: timeout, connection refused, DNS failure — may recover on retry. */
|
|
24
|
+
export class NetworkError extends PaymentLibError {}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @suluk/payments — provider-agnostic payments for a Suluk app (C048). A Workers-native TypeScript reimplementation of the
|
|
3
|
+
* Hyperswitch Prism connector interface: ONE unified request schema (authorize / capture / void / refund / sync + the
|
|
4
|
+
* optional customer / tokenize / recurring / webhook surfaces), and you switch processor by CONFIG, not code. Prism itself
|
|
5
|
+
* is a native FFI addon that can't run on the edge, so we adopt its interface + status semantics (integer-exact) and
|
|
6
|
+
* implement over `fetch` — zero native deps, light, swappable. This barrel is the INTERFACE + a mock connector; the real
|
|
7
|
+
* connectors (stripe, adyen, …) and the @suluk/billing rewire that deprecates @suluk/stripe are the follow-on builds.
|
|
8
|
+
*/
|
|
9
|
+
export * from "./types";
|
|
10
|
+
export { IntegrationError, ConnectorError, NetworkError, PaymentLibError } from "./errors";
|
|
11
|
+
export {
|
|
12
|
+
paymentClient,
|
|
13
|
+
type PaymentConnector, type ConnectorConfig, type ConnectorAuth, type ConnectorFactory, type ConnectorRegistry,
|
|
14
|
+
type HttpOptions, type WebhookEvent,
|
|
15
|
+
} from "./connector";
|
|
16
|
+
export { mockConnector, MOCK_DECLINE_CARD, MOCK_3DS_CARD } from "./mock";
|
|
17
|
+
// the built-in Stripe connector (the first real backend; fetch → Stripe REST, Workers-native).
|
|
18
|
+
export { stripeConnector } from "./connectors/stripe";
|
|
19
|
+
// the low-level Stripe transport (one Stripe client) — for the Stripe-PLATFORM ops the agnostic seam doesn't model.
|
|
20
|
+
export { type StripeConfig, stripePost, stripeGet, toForm } from "./stripe-transport";
|
|
21
|
+
// pricing primitives — processor-agnostic checkout money math (moved from @suluk/stripe; anti-tampering, proration, …).
|
|
22
|
+
export {
|
|
23
|
+
subtotal, computeDiscountAmount, validateDiscount, prorateDiscount, orderTotal, composeTotal, verifyAmount,
|
|
24
|
+
cartFingerprint, idempotencyKey, requiresStripe, STRIPE_MIN_CHARGE_CENTS,
|
|
25
|
+
type CartLine, type Discount, type DiscountResult, type DiscountRejection, type OrderTotal, type OrderTotalFull, type AmountVerdict,
|
|
26
|
+
} from "./pricing";
|
|
27
|
+
// the Stripe webhook surface (SDK-free signature verification + a typed event router; moved from @suluk/stripe).
|
|
28
|
+
export {
|
|
29
|
+
verifyStripeSignature, timingSafeHexEqual, webhookRouter, STRIPE_EVENTS,
|
|
30
|
+
type VerifyOptions, type StripeWebhookEvent, type WebhookHandler, type WebhookRouter, type HandleResult,
|
|
31
|
+
} from "./stripe-webhook";
|
package/src/mock.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A mock connector (C048) — proves the {@link PaymentConnector} seam end-to-end without a real processor, and doubles as a
|
|
3
|
+
* deterministic local/test stand-in (like a processor's test mode). It keeps a tiny in-memory ledger so capture/void/
|
|
4
|
+
* refund/sync are coherent across a flow. Behaviour keys off the PAN: a decline test card → in-band `FAILURE`; a 3DS
|
|
5
|
+
* card (or `THREE_DS` auth) → `AUTHENTICATION_PENDING` + redirection; otherwise `CHARGED` (auto) / `AUTHORIZED` (manual).
|
|
6
|
+
* This is NOT a payment processor — the real connectors (stripe, adyen, …) are the follow-on build.
|
|
7
|
+
*/
|
|
8
|
+
import type { ConnectorFactory, PaymentConnector } from "./connector";
|
|
9
|
+
import { IntegrationError } from "./errors";
|
|
10
|
+
import { AuthenticationType, CaptureMethod, PaymentStatus, RefundStatus, type MinorAmount } from "./types";
|
|
11
|
+
|
|
12
|
+
/** Well-known test PANs (Stripe-compatible values, for familiarity). */
|
|
13
|
+
export const MOCK_DECLINE_CARD = "4000000000000002";
|
|
14
|
+
export const MOCK_3DS_CARD = "4000000000003220";
|
|
15
|
+
|
|
16
|
+
export const mockConnector: ConnectorFactory = (auth) => {
|
|
17
|
+
if (!auth.apiKey?.value) throw new IntegrationError("INVALID_CONFIGURATION", "mock connector needs an apiKey");
|
|
18
|
+
const ledger = new Map<string, { amount: MinorAmount; status: PaymentStatus }>();
|
|
19
|
+
let seq = 0;
|
|
20
|
+
const id = () => `mock_${++seq}`;
|
|
21
|
+
|
|
22
|
+
const connector: PaymentConnector = {
|
|
23
|
+
name: "mock",
|
|
24
|
+
async authorize(req) {
|
|
25
|
+
const pan = req.paymentMethod.card?.cardNumber.value ?? req.paymentMethod.token?.value ?? "";
|
|
26
|
+
if (pan === MOCK_DECLINE_CARD) return { status: PaymentStatus.FAILURE, error: { code: "card_declined", message: "Your card was declined." } };
|
|
27
|
+
if (req.authType === AuthenticationType.THREE_DS || pan === MOCK_3DS_CARD) {
|
|
28
|
+
const txn = id();
|
|
29
|
+
ledger.set(txn, { amount: req.amount, status: PaymentStatus.AUTHENTICATION_PENDING });
|
|
30
|
+
return { status: PaymentStatus.AUTHENTICATION_PENDING, connectorTransactionId: txn, redirectionData: { url: req.returnUrl ?? "https://mock.test/3ds", method: "GET" } };
|
|
31
|
+
}
|
|
32
|
+
const txn = id();
|
|
33
|
+
const status = req.captureMethod === CaptureMethod.MANUAL ? PaymentStatus.AUTHORIZED : PaymentStatus.CHARGED;
|
|
34
|
+
ledger.set(txn, { amount: req.amount, status });
|
|
35
|
+
return { status, connectorTransactionId: txn, amount: req.amount };
|
|
36
|
+
},
|
|
37
|
+
async capture(req) {
|
|
38
|
+
const t = ledger.get(req.connectorTransactionId);
|
|
39
|
+
if (!t) return { status: PaymentStatus.CAPTURE_FAILED, error: { code: "not_found", message: "unknown transaction" } };
|
|
40
|
+
t.status = PaymentStatus.CHARGED;
|
|
41
|
+
return { status: PaymentStatus.CHARGED, connectorTransactionId: req.connectorTransactionId, amount: req.amountToCapture };
|
|
42
|
+
},
|
|
43
|
+
async void(req) {
|
|
44
|
+
const t = ledger.get(req.connectorTransactionId);
|
|
45
|
+
if (!t) return { status: PaymentStatus.VOID_FAILED, error: { code: "not_found", message: "unknown transaction" } };
|
|
46
|
+
t.status = PaymentStatus.VOIDED;
|
|
47
|
+
return { status: PaymentStatus.VOIDED, connectorTransactionId: req.connectorTransactionId };
|
|
48
|
+
},
|
|
49
|
+
async refund(req) {
|
|
50
|
+
const t = ledger.get(req.connectorTransactionId);
|
|
51
|
+
if (!t) return { status: RefundStatus.REFUND_FAILURE, error: { code: "not_found", message: "unknown transaction" } };
|
|
52
|
+
return { status: RefundStatus.REFUND_SUCCESS, connectorRefundId: `mock_re_${++seq}` };
|
|
53
|
+
},
|
|
54
|
+
async sync(req) {
|
|
55
|
+
const t = ledger.get(req.connectorTransactionId);
|
|
56
|
+
return t ? { status: t.status, connectorTransactionId: req.connectorTransactionId, amount: t.amount } : { status: PaymentStatus.UNSPECIFIED };
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
return connector;
|
|
60
|
+
};
|
package/src/pricing.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MONEY — pure, side-effect-free pricing primitives (saastarter-parity roadmap, Phase 0; PARITY §2 trust layer).
|
|
3
|
+
*
|
|
4
|
+
* The checkout-resilience cluster is "invisible until the edge case hits; exactly what separates a toy cart from
|
|
5
|
+
* one you'd trust with money." Its core is arithmetic that MUST be authored once and conformance-tested, never
|
|
6
|
+
* re-derived per app where the cart-drawer total and the order-summary total silently drift:
|
|
7
|
+
*
|
|
8
|
+
* • all money is INTEGER minor units (cents) — never a float (0.1 + 0.2 has no place near money);
|
|
9
|
+
* • a discount NEVER exceeds the subtotal (no negative totals, no free-money over-discount);
|
|
10
|
+
* • per-line proration sums EXACTLY to the order discount (largest-remainder), so every surface agrees;
|
|
11
|
+
* • verifyAmount RECOMPUTES the total from authoritative prices and rejects a client-supplied amount that
|
|
12
|
+
* doesn't match (anti-tampering — never trust the amount the browser sends);
|
|
13
|
+
* • a deterministic idempotency key from the cart lets a retry REUSE one payment intent (no double-charge).
|
|
14
|
+
*
|
|
15
|
+
* The BUSINESS rules around a discount (is it active? in its date window? under its usage cap? category-limited?)
|
|
16
|
+
* stay the app's concern — this module is the MATH + the structural validation, the part every store shares.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** One cart line. `unitCents` is the authoritative price (from the server, not the client). */
|
|
20
|
+
export interface CartLine { unitCents: number; qty: number; id?: string | number }
|
|
21
|
+
|
|
22
|
+
/** A discount's MATH shape (the structural part; app-side eligibility rules are separate). */
|
|
23
|
+
export interface Discount {
|
|
24
|
+
type: "percent" | "fixed";
|
|
25
|
+
/** percent: 0–100; fixed: cents off the subtotal. */
|
|
26
|
+
value: number;
|
|
27
|
+
/** the discount only applies at/above this subtotal (cents). */
|
|
28
|
+
minSubtotalCents?: number;
|
|
29
|
+
/** cap the amount removed (cents) — e.g. "30% off, up to $50". Applied before the [0, subtotal] clamp. */
|
|
30
|
+
maxDiscountCents?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Stripe's minimum chargeable amount (USD). Below it, a charge is impossible — the order must go the free path. */
|
|
34
|
+
export const STRIPE_MIN_CHARGE_CENTS = 50;
|
|
35
|
+
|
|
36
|
+
/** Does this total require a real Stripe charge, or can it complete as a free order? Centralizes the $0.50 floor
|
|
37
|
+
* decision so the free-checkout branch and the Stripe branch can never disagree about where $0–$0.49 goes. */
|
|
38
|
+
export function requiresStripe(totalCents: number): boolean {
|
|
39
|
+
return Math.max(0, Math.round(fin(totalCents))) >= STRIPE_MIN_CHARGE_CENTS;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DiscountResult { valid: boolean; amountCents: number; reason?: DiscountRejection }
|
|
43
|
+
export type DiscountRejection = "no-discount" | "non-positive-value" | "percent-out-of-range" | "below-minimum";
|
|
44
|
+
|
|
45
|
+
export interface OrderTotal { subtotalCents: number; discountCents: number; totalCents: number }
|
|
46
|
+
export interface AmountVerdict { ok: boolean; expectedCents: number; claimedCents: number; deltaCents: number; reason?: "amount-mismatch" }
|
|
47
|
+
|
|
48
|
+
const sum = (ns: number[]) => ns.reduce((a, b) => a + b, 0);
|
|
49
|
+
/** Coerce a non-finite number (NaN/±Infinity) to 0 — money math must never propagate poison. */
|
|
50
|
+
const fin = (n: number) => (Number.isFinite(n) ? n : 0);
|
|
51
|
+
const lineTotal = (l: CartLine) => Math.max(0, Math.round(fin(l.unitCents))) * Math.max(0, Math.trunc(fin(l.qty)));
|
|
52
|
+
|
|
53
|
+
/** Subtotal in cents — integer, non-negative. */
|
|
54
|
+
export function subtotal(lines: CartLine[]): number {
|
|
55
|
+
return sum(lines.map(lineTotal));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The cents a discount removes from `subtotalCents` — ROUNDED to a whole cent and CLAMPED to [0, subtotal] so a
|
|
60
|
+
* discount can never exceed the order or go negative. Validation (eligibility) is `validateDiscount`; this is the
|
|
61
|
+
* raw amount assuming the discount applies.
|
|
62
|
+
*/
|
|
63
|
+
export function computeDiscountAmount(subtotalCents: number, d: Discount): number {
|
|
64
|
+
const base = Math.max(0, Math.round(fin(subtotalCents)));
|
|
65
|
+
if (base === 0 || !Number.isFinite(d.value) || d.value <= 0) return 0; // a non-finite/non-positive value buys nothing
|
|
66
|
+
const raw = d.type === "percent" ? (base * d.value) / 100 : d.value;
|
|
67
|
+
// cap a percentage (or fixed) discount at maxDiscountCents if set ("30% off, up to $50"), then clamp to [0, subtotal].
|
|
68
|
+
const capped = d.maxDiscountCents != null && Number.isFinite(d.maxDiscountCents) ? Math.min(raw, Math.max(0, d.maxDiscountCents)) : raw;
|
|
69
|
+
return Math.min(base, Math.max(0, Math.round(capped)));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Validate a discount against a subtotal, with a SPECIFIC rejection reason (PARITY: "specific discount-rejection
|
|
74
|
+
* reasons" — a shopper is told *why*, not just "invalid"). Structural only; the app layers active/window/usage.
|
|
75
|
+
*/
|
|
76
|
+
export function validateDiscount(subtotalCents: number, d: Discount | undefined | null): DiscountResult {
|
|
77
|
+
if (!d) return { valid: false, amountCents: 0, reason: "no-discount" };
|
|
78
|
+
if (d.value <= 0) return { valid: false, amountCents: 0, reason: "non-positive-value" };
|
|
79
|
+
if (d.type === "percent" && d.value > 100) return { valid: false, amountCents: 0, reason: "percent-out-of-range" };
|
|
80
|
+
if (d.minSubtotalCents != null && subtotalCents < d.minSubtotalCents) return { valid: false, amountCents: 0, reason: "below-minimum" };
|
|
81
|
+
return { valid: true, amountCents: computeDiscountAmount(subtotalCents, d) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Split `discountCents` across `lines` proportionally to each line's total, as whole cents that sum EXACTLY to
|
|
86
|
+
* `discountCents` (largest-remainder apportionment). This is what keeps the cart drawer and the order summary
|
|
87
|
+
* from disagreeing by a cent. Each line's share is clamped to its own total.
|
|
88
|
+
*/
|
|
89
|
+
export function prorateDiscount(lines: CartLine[], discountCents: number): number[] {
|
|
90
|
+
const totals = lines.map(lineTotal);
|
|
91
|
+
const gross = sum(totals);
|
|
92
|
+
const want = Math.min(Math.max(0, Math.round(discountCents)), gross);
|
|
93
|
+
if (gross <= 0 || want <= 0) return lines.map(() => 0);
|
|
94
|
+
const exact = totals.map((t) => (want * t) / gross);
|
|
95
|
+
const shares = exact.map((e, i) => Math.min(totals[i], Math.floor(e)));
|
|
96
|
+
let remainder = want - sum(shares);
|
|
97
|
+
// hand the leftover cents to the lines with the largest fractional part that still have headroom
|
|
98
|
+
const order = exact
|
|
99
|
+
.map((e, i) => ({ i, frac: e - Math.floor(e) }))
|
|
100
|
+
.sort((a, b) => b.frac - a.frac || a.i - b.i);
|
|
101
|
+
for (let k = 0; remainder > 0 && k < order.length * 2; k++) {
|
|
102
|
+
const { i } = order[k % order.length];
|
|
103
|
+
if (shares[i] < totals[i]) { shares[i] += 1; remainder -= 1; }
|
|
104
|
+
}
|
|
105
|
+
return shares;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Compose the authoritative order total from lines + an optional (already-validated) discount. */
|
|
109
|
+
export function orderTotal(lines: CartLine[], discount?: Discount | null): OrderTotal {
|
|
110
|
+
const subtotalCents = subtotal(lines);
|
|
111
|
+
const discountCents = discount ? validateDiscount(subtotalCents, discount).amountCents : 0;
|
|
112
|
+
return { subtotalCents, discountCents, totalCents: subtotalCents - discountCents };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The full order total once shipping + tax (from the ./shipping + ./tax adapters) are known. */
|
|
116
|
+
export interface OrderTotalFull { subtotalCents: number; discountCents: number; shippingCents: number; taxCents: number; totalCents: number }
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Fold every component into ONE authoritative total: subtotal − discount + shipping + tax, each a non-negative whole
|
|
120
|
+
* cent and the discount never exceeding the subtotal. The single place the order total is composed once shipping (a
|
|
121
|
+
* ShippingOption) and tax (a TaxResult) are resolved — so the cart drawer, checkout summary, order record, and the
|
|
122
|
+
* Stripe charge can never disagree.
|
|
123
|
+
*/
|
|
124
|
+
export function composeTotal(parts: { subtotalCents: number; discountCents?: number; shippingCents?: number; taxCents?: number }): OrderTotalFull {
|
|
125
|
+
const subtotalCents = Math.max(0, Math.round(fin(parts.subtotalCents)));
|
|
126
|
+
const discountCents = Math.min(subtotalCents, Math.max(0, Math.round(fin(parts.discountCents ?? 0))));
|
|
127
|
+
const shippingCents = Math.max(0, Math.round(fin(parts.shippingCents ?? 0)));
|
|
128
|
+
const taxCents = Math.max(0, Math.round(fin(parts.taxCents ?? 0)));
|
|
129
|
+
return { subtotalCents, discountCents, shippingCents, taxCents, totalCents: subtotalCents - discountCents + shippingCents + taxCents };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* ANTI-TAMPERING: recompute the total from authoritative line prices + the discount and compare it to the amount
|
|
134
|
+
* the client claims (e.g. a PaymentIntent amount the browser posted). Reject any mismatch beyond `toleranceCents`
|
|
135
|
+
* (default 0 — money is exact). The server must call this before honoring any client-supplied amount.
|
|
136
|
+
*/
|
|
137
|
+
export function verifyAmount(lines: CartLine[], discount: Discount | null | undefined, claimedCents: number, opts: { toleranceCents?: number } = {}): AmountVerdict {
|
|
138
|
+
const expectedCents = orderTotal(lines, discount).totalCents;
|
|
139
|
+
const deltaCents = Math.round(claimedCents) - expectedCents;
|
|
140
|
+
const ok = Math.abs(deltaCents) <= (opts.toleranceCents ?? 0);
|
|
141
|
+
return { ok, expectedCents, claimedCents: Math.round(claimedCents), deltaCents, ...(ok ? {} : { reason: "amount-mismatch" as const }) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A stable fingerprint of the priced cart (+ discount) — order-independent over lines. Two carts that should be
|
|
146
|
+
* charged identically produce the same fingerprint; any price/qty/discount change produces a different one.
|
|
147
|
+
*/
|
|
148
|
+
export function cartFingerprint(lines: CartLine[], discount?: Discount | null): string {
|
|
149
|
+
const norm = lines
|
|
150
|
+
.map((l) => `${l.id ?? "?"}:${Math.round(l.unitCents)}x${Math.trunc(l.qty)}`)
|
|
151
|
+
.sort()
|
|
152
|
+
.join("|");
|
|
153
|
+
const d = discount ? `${discount.type}:${discount.value}:${discount.minSubtotalCents ?? ""}` : "";
|
|
154
|
+
const s = `${norm}#${d}`;
|
|
155
|
+
let h = 2166136261;
|
|
156
|
+
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; }
|
|
157
|
+
return h.toString(16).padStart(8, "0");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A deterministic idempotency key for a checkout attempt. The SAME cart under the same scope (principal) yields
|
|
162
|
+
* the SAME key, so a retried "create payment intent" REUSES the existing intent instead of charging twice; a
|
|
163
|
+
* changed cart yields a new key. Thread this into the processor's idempotency-key header.
|
|
164
|
+
*/
|
|
165
|
+
export function idempotencyKey(scope: string, lines: CartLine[], discount?: Discount | null): string {
|
|
166
|
+
return `co_${scope}_${cartFingerprint(lines, discount)}`;
|
|
167
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The low-level Stripe HTTP transport (C048) — the fetch-based Stripe client the {@link stripeConnector} rides, exported
|
|
3
|
+
* so an app's Stripe-PLATFORM operations (hosted Checkout, subscriptions, saved-card management, Tax — the things the
|
|
4
|
+
* agnostic PaymentConnector deliberately doesn't model) ride the SAME client instead of a separate legacy one. This is
|
|
5
|
+
* intentionally Stripe-specific: agnostic payment FLOWS go through the connector, these platform ops through this
|
|
6
|
+
* transport — one Stripe roof, no accidental second path. Workers-native (fetch + x-www-form-urlencoded), zero deps.
|
|
7
|
+
*/
|
|
8
|
+
export interface StripeConfig {
|
|
9
|
+
secretKey: string;
|
|
10
|
+
/** the HTTP transport — a mock in tests; defaults to the global `fetch` in prod. */
|
|
11
|
+
fetch?: typeof fetch;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const BASE = "https://api.stripe.com/v1";
|
|
15
|
+
|
|
16
|
+
export const stripePost = (cfg: StripeConfig, path: string, form: URLSearchParams, idempotencyKey?: string): Promise<Response> =>
|
|
17
|
+
(cfg.fetch ?? fetch)(`${BASE}/${path}`, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: {
|
|
20
|
+
authorization: `Bearer ${cfg.secretKey}`,
|
|
21
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
22
|
+
// refunds/charges pass one so a retry of the SAME logical operation never moves money twice (Stripe replays it).
|
|
23
|
+
...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}),
|
|
24
|
+
},
|
|
25
|
+
body: form.toString(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const stripeGet = (cfg: StripeConfig, path: string): Promise<Response> =>
|
|
29
|
+
(cfg.fetch ?? fetch)(`${BASE}/${path}`, { headers: { authorization: `Bearer ${cfg.secretKey}` } });
|
|
30
|
+
|
|
31
|
+
/** Stripe's bracket-nested `x-www-form-urlencoded` encoder: objects → `key[k]`, arrays → `key[i]`, scalars appended;
|
|
32
|
+
* undefined/null are skipped. Handles the nested arrays checkout/subscription payloads need (`line_items[0][price]`). */
|
|
33
|
+
export function toForm(obj: Record<string, unknown>): URLSearchParams {
|
|
34
|
+
const out = new URLSearchParams();
|
|
35
|
+
for (const [k, v] of Object.entries(obj)) encode(k, v, out);
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function encode(key: string, v: unknown, out: URLSearchParams): void {
|
|
40
|
+
if (v === undefined || v === null) return;
|
|
41
|
+
if (Array.isArray(v)) v.forEach((item, i) => encode(`${key}[${i}]`, item, out));
|
|
42
|
+
else if (typeof v === "object") for (const [k, val] of Object.entries(v as Record<string, unknown>)) encode(`${key}[${k}]`, val, out);
|
|
43
|
+
else out.append(key, String(v));
|
|
44
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Stripe webhook surface (C048) — moved here from @suluk/stripe so the whole payment story (flows + transport +
|
|
3
|
+
* pricing + webhooks) lives under @suluk/payments. Two pieces: SDK-free signature VERIFICATION (Web Crypto HMAC-SHA256 —
|
|
4
|
+
* Workers/Bun/Node 18+, no `stripe` SDK) and a typed event ROUTER (dispatch a verified event to a per-type handler
|
|
5
|
+
* instead of one giant switch). Pure of any SDK + network. @suluk/stripe re-exports these as a deprecated shim.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ── signature verification ────────────────────────────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/** Constant-time hex-string compare (no early-out) — guards the signature check against timing oracles. */
|
|
11
|
+
export function timingSafeHexEqual(a: string, b: string): boolean {
|
|
12
|
+
if (a.length !== b.length) return false;
|
|
13
|
+
let diff = 0;
|
|
14
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
15
|
+
return diff === 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface VerifyOptions {
|
|
19
|
+
/** current unix seconds (default `Date.now()/1000`) — injectable for tests + replay-window tuning. */
|
|
20
|
+
now?: () => number;
|
|
21
|
+
/** reject events whose timestamp is older than this many seconds (default 300 — Stripe's window). */
|
|
22
|
+
toleranceSec?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Verify a Stripe `stripe-signature` header against the raw request body + the endpoint signing secret. Returns true iff
|
|
27
|
+
* a v1 signature matches the HMAC of `${t}.${rawBody}` AND the timestamp is within tolerance. Pass the RAW (unparsed)
|
|
28
|
+
* body — re-serializing JSON changes the bytes and breaks the HMAC.
|
|
29
|
+
*/
|
|
30
|
+
export async function verifyStripeSignature(rawBody: string, sigHeader: string, secret: string, opts: VerifyOptions = {}): Promise<boolean> {
|
|
31
|
+
if (!rawBody || !sigHeader || !secret) return false;
|
|
32
|
+
const parts: Record<string, string> = {};
|
|
33
|
+
for (const p of sigHeader.split(",")) { const i = p.indexOf("="); if (i > 0 && !(p.slice(0, i) in parts)) parts[p.slice(0, i)] = p.slice(i + 1); } // split on the FIRST '=' only
|
|
34
|
+
const ts = Number(parts.t);
|
|
35
|
+
if (!parts.t || !parts.v1 || !Number.isFinite(ts)) return false;
|
|
36
|
+
const now = (opts.now ?? (() => Date.now() / 1000))();
|
|
37
|
+
if (Math.abs(now - ts) > (opts.toleranceSec ?? 300)) return false; // reject stale/replayed events (Stripe's 5-min window)
|
|
38
|
+
const keyData = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
39
|
+
const mac = await crypto.subtle.sign("HMAC", keyData, new TextEncoder().encode(`${ts}.${rawBody}`));
|
|
40
|
+
const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
41
|
+
return timingSafeHexEqual(expected, parts.v1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── the typed event router ────────────────────────────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/** A verified webhook event — only `type` is required (the router dispatches on it); `data` carries the payload. */
|
|
47
|
+
export interface StripeWebhookEvent {
|
|
48
|
+
type: string;
|
|
49
|
+
data?: unknown;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type WebhookHandler = (event: StripeWebhookEvent) => void | Promise<void>;
|
|
53
|
+
|
|
54
|
+
export interface HandleResult {
|
|
55
|
+
type: string;
|
|
56
|
+
/** a registered handler ran (false ⇒ the unhandled fallback ran, or nothing matched). */
|
|
57
|
+
handled: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface WebhookRouter {
|
|
61
|
+
/** register (or replace) the handler for an event type; chainable. */
|
|
62
|
+
on(type: string, handler: WebhookHandler): WebhookRouter;
|
|
63
|
+
/** register a fallback for types with no specific handler; chainable. */
|
|
64
|
+
onUnhandled(handler: WebhookHandler): WebhookRouter;
|
|
65
|
+
/** dispatch one verified event to its handler. */
|
|
66
|
+
handle(event: StripeWebhookEvent): Promise<HandleResult>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Build a router, optionally seeded with a `{ type → handler }` map. */
|
|
70
|
+
export function webhookRouter(handlers: Record<string, WebhookHandler> = {}): WebhookRouter {
|
|
71
|
+
const map = new Map<string, WebhookHandler>(Object.entries(handlers));
|
|
72
|
+
let fallback: WebhookHandler | undefined;
|
|
73
|
+
const router: WebhookRouter = {
|
|
74
|
+
on(type, handler) { map.set(type, handler); return router; },
|
|
75
|
+
onUnhandled(handler) { fallback = handler; return router; },
|
|
76
|
+
async handle(event) {
|
|
77
|
+
const handler = map.get(event.type);
|
|
78
|
+
if (handler) { await handler(event); return { type: event.type, handled: true }; }
|
|
79
|
+
if (fallback) await fallback(event);
|
|
80
|
+
return { type: event.type, handled: false };
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
return router;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The common Stripe checkout/billing event types (for discoverability + typo-safe registration). */
|
|
87
|
+
export const STRIPE_EVENTS = {
|
|
88
|
+
checkoutCompleted: "checkout.session.completed",
|
|
89
|
+
checkoutExpired: "checkout.session.expired",
|
|
90
|
+
paymentSucceeded: "payment_intent.succeeded",
|
|
91
|
+
paymentFailed: "payment_intent.payment_failed",
|
|
92
|
+
chargeRefunded: "charge.refunded",
|
|
93
|
+
disputeClosed: "charge.dispute.closed",
|
|
94
|
+
setupSucceeded: "setup_intent.succeeded",
|
|
95
|
+
subscriptionUpdated: "customer.subscription.updated",
|
|
96
|
+
subscriptionDeleted: "customer.subscription.deleted",
|
|
97
|
+
invoicePaid: "invoice.paid",
|
|
98
|
+
} as const;
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The unified payment schema (C048) — a Workers-native TypeScript reimplementation of the Hyperswitch Prism interface.
|
|
3
|
+
* Prism ships as a native FFI addon (Rust core) that can't run in a Cloudflare Worker, so we adopt its INTERFACE — one
|
|
4
|
+
* request schema for every processor — and implement it ourselves over `fetch` (zero native deps, edge-safe). The status
|
|
5
|
+
* enums mirror Prism's INTEGER values exactly, so a real Prism backend stays a drop-in later and connector semantics
|
|
6
|
+
* match. Sensitive values are wrapped in {@link Secret} (a PCI-scope signal — the library never logs them).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A processor-side secret / PII value — wrapped so it's explicit at every call site and never accidentally logged. */
|
|
10
|
+
export type Secret<T = string> = { value: T };
|
|
11
|
+
|
|
12
|
+
/** ISO-4217 currency. A curated set for `Currency.USD`-style access; open to any code a connector accepts. */
|
|
13
|
+
export const Currency = { USD: "USD", EUR: "EUR", GBP: "GBP", AED: "AED", SAR: "SAR", INR: "INR", CAD: "CAD", AUD: "AUD" } as const;
|
|
14
|
+
export type Currency = (typeof Currency)[keyof typeof Currency] | (string & {});
|
|
15
|
+
|
|
16
|
+
/** Auto-capture on authorize, or authorize-then-capture-later. */
|
|
17
|
+
export enum CaptureMethod {
|
|
18
|
+
AUTOMATIC = "AUTOMATIC",
|
|
19
|
+
MANUAL = "MANUAL",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 3-D Secure preference. */
|
|
23
|
+
export enum AuthenticationType {
|
|
24
|
+
NO_THREE_DS = "NO_THREE_DS",
|
|
25
|
+
THREE_DS = "THREE_DS",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Payment status — INTEGER values mirroring Prism exactly (do NOT renumber; a real Prism backend + connector code depend
|
|
30
|
+
* on these). A soft decline is `FAILURE` returned IN-BAND on the response (never thrown). Use with authorize/capture/void.
|
|
31
|
+
*/
|
|
32
|
+
export enum PaymentStatus {
|
|
33
|
+
UNSPECIFIED = 0,
|
|
34
|
+
STARTED = 1,
|
|
35
|
+
AUTHENTICATION_FAILED = 2,
|
|
36
|
+
ROUTER_DECLINED = 3,
|
|
37
|
+
AUTHENTICATION_PENDING = 4,
|
|
38
|
+
AUTHENTICATION_SUCCESSFUL = 5,
|
|
39
|
+
AUTHORIZED = 6,
|
|
40
|
+
AUTHORIZATION_FAILED = 7,
|
|
41
|
+
CHARGED = 8,
|
|
42
|
+
VOIDED = 11,
|
|
43
|
+
VOID_INITIATED = 12,
|
|
44
|
+
CAPTURE_INITIATED = 13,
|
|
45
|
+
CAPTURE_FAILED = 14,
|
|
46
|
+
VOID_FAILED = 15,
|
|
47
|
+
PARTIAL_CHARGED = 17,
|
|
48
|
+
UNRESOLVED = 19,
|
|
49
|
+
PENDING = 20,
|
|
50
|
+
FAILURE = 21,
|
|
51
|
+
PARTIALLY_AUTHORIZED = 25,
|
|
52
|
+
EXPIRED = 26,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Refund status — a SEPARATE enum from {@link PaymentStatus} with overlapping integers (mirrors Prism). `REFUND_PENDING`
|
|
56
|
+
* is a normal success state for many processors — treat PENDING + SUCCESS both as success. */
|
|
57
|
+
export enum RefundStatus {
|
|
58
|
+
UNSPECIFIED = 0,
|
|
59
|
+
REFUND_FAILURE = 1,
|
|
60
|
+
REFUND_MANUAL_REVIEW = 2,
|
|
61
|
+
REFUND_PENDING = 3,
|
|
62
|
+
REFUND_SUCCESS = 4,
|
|
63
|
+
REFUND_TRANSACTION_FAILURE = 5,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** An amount in the currency's minor unit (cents), the Prism convention (no floats in the money path). */
|
|
67
|
+
export interface MinorAmount {
|
|
68
|
+
minorAmount: number;
|
|
69
|
+
currency: Currency;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface CardDetails {
|
|
73
|
+
cardNumber: Secret;
|
|
74
|
+
cardExpMonth: Secret;
|
|
75
|
+
cardExpYear: Secret;
|
|
76
|
+
cardCvc: Secret;
|
|
77
|
+
cardHolderName?: Secret;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The payment instrument. Extend with wallet / bank-transfer as connectors gain coverage; card + token are the core.
|
|
81
|
+
* `token` is a saved/vaulted instrument id (the app's or the processor's vault — the library stores nothing). */
|
|
82
|
+
export interface PaymentMethod {
|
|
83
|
+
card?: CardDetails;
|
|
84
|
+
token?: Secret;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface Address {
|
|
88
|
+
line1?: string;
|
|
89
|
+
line2?: string;
|
|
90
|
+
city?: string;
|
|
91
|
+
state?: string;
|
|
92
|
+
postalCode?: string;
|
|
93
|
+
country?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface OrderDetail {
|
|
97
|
+
description: string;
|
|
98
|
+
quantity?: number;
|
|
99
|
+
amount?: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A structured error the library surfaces (in-band on FAILURE, or on a thrown IntegrationError/ConnectorError). Only
|
|
103
|
+
* primitive fields — never a processor's raw object (which may not be serializable). */
|
|
104
|
+
export interface PaymentError {
|
|
105
|
+
message?: string;
|
|
106
|
+
code?: string;
|
|
107
|
+
reason?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Where to send the customer for 3DS / redirect flows (present when status is AUTHENTICATION_PENDING). */
|
|
111
|
+
export interface RedirectionData {
|
|
112
|
+
url?: string;
|
|
113
|
+
method?: "GET" | "POST";
|
|
114
|
+
fields?: Record<string, string>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Requests / responses (mirroring Prism's PaymentService* shapes) ───────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
export interface AuthorizeRequest {
|
|
120
|
+
merchantTransactionId: string;
|
|
121
|
+
amount: MinorAmount;
|
|
122
|
+
captureMethod: CaptureMethod;
|
|
123
|
+
paymentMethod: PaymentMethod;
|
|
124
|
+
authType: AuthenticationType;
|
|
125
|
+
address?: Address;
|
|
126
|
+
returnUrl?: string;
|
|
127
|
+
orderDetails?: OrderDetail[];
|
|
128
|
+
/** an existing processor customer to attach the charge to (optional). */
|
|
129
|
+
customerId?: string;
|
|
130
|
+
/** save the instrument for later off-session use (recurring / one-click). */
|
|
131
|
+
setupFutureUsage?: boolean;
|
|
132
|
+
/** the charge is happening WITHOUT the cardholder present (auto top-up / recurring) — the processor may decline for
|
|
133
|
+
* 3DS (`AUTHENTICATION_PENDING`) rather than charge. Maps to Stripe `off_session`, Adyen `ContAuth`, etc. */
|
|
134
|
+
offSession?: boolean;
|
|
135
|
+
/** free-form key/value the processor stores + echoes on its webhook (e.g. `{ userId, credits }` the crediting path
|
|
136
|
+
* reads). Most processors support it (Stripe metadata, Adyen additionalData). */
|
|
137
|
+
metadata?: Record<string, string>;
|
|
138
|
+
testMode?: boolean;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface PaymentResponse {
|
|
142
|
+
status: PaymentStatus;
|
|
143
|
+
connectorTransactionId?: string;
|
|
144
|
+
redirectionData?: RedirectionData;
|
|
145
|
+
error?: PaymentError;
|
|
146
|
+
/** the amount actually captured/authorized, when the processor reports it. */
|
|
147
|
+
amount?: MinorAmount;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface CaptureRequest {
|
|
151
|
+
merchantCaptureId: string;
|
|
152
|
+
connectorTransactionId: string;
|
|
153
|
+
amountToCapture: MinorAmount;
|
|
154
|
+
testMode?: boolean;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface VoidRequest {
|
|
158
|
+
merchantVoidId: string;
|
|
159
|
+
connectorTransactionId: string;
|
|
160
|
+
cancellationReason?: string;
|
|
161
|
+
testMode?: boolean;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface RefundRequest {
|
|
165
|
+
merchantRefundId: string;
|
|
166
|
+
connectorTransactionId: string;
|
|
167
|
+
refundAmount: MinorAmount;
|
|
168
|
+
/** the ORIGINAL payment amount (minor units) — some processors require it to compute a partial refund. */
|
|
169
|
+
paymentAmount: number;
|
|
170
|
+
reason?: string;
|
|
171
|
+
testMode?: boolean;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface RefundResponse {
|
|
175
|
+
status: RefundStatus;
|
|
176
|
+
connectorRefundId?: string;
|
|
177
|
+
error?: PaymentError;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface SyncRequest {
|
|
181
|
+
connectorTransactionId: string;
|
|
182
|
+
testMode?: boolean;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── the client-token surface (Prism's MerchantAuthenticationClient) — browser-confirmable sessions ────────────────────
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* A browser-confirmable session: the server creates the intent, the browser SDK confirms it with `clientSecret`, so raw
|
|
189
|
+
* card data never touches the server (PCI-scope reduction). Crediting lands on the processor webhook, not the create
|
|
190
|
+
* call. This is the piece a pure server-side `authorize` can't express — the Payment-Element / one-click / add-card flows.
|
|
191
|
+
*/
|
|
192
|
+
export interface ClientSession {
|
|
193
|
+
/** the token the browser SDK confirms with — Stripe's `client_secret`; another processor's equivalent. */
|
|
194
|
+
clientSecret: string;
|
|
195
|
+
connectorTransactionId?: string;
|
|
196
|
+
customerId?: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Create a PAYMENT session to confirm in-browser. Omit `paymentMethod` for a Payment-Element flow (the browser collects
|
|
200
|
+
* the card); pass a saved `paymentMethod` token for a one-click charge on a saved card. */
|
|
201
|
+
export interface CreatePaymentSessionRequest {
|
|
202
|
+
amount: MinorAmount;
|
|
203
|
+
customerId?: string;
|
|
204
|
+
/** pin the charge to a saved instrument (one-click); omit to let the browser collect one (Payment Element). */
|
|
205
|
+
paymentMethod?: Secret;
|
|
206
|
+
captureMethod?: CaptureMethod;
|
|
207
|
+
/** save the collected card for later off-session use. */
|
|
208
|
+
setupFutureUsage?: boolean;
|
|
209
|
+
metadata?: Record<string, string>;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Create a SETUP session to vault a card without charging ("add card"). */
|
|
213
|
+
export interface CreateSetupSessionRequest {
|
|
214
|
+
customerId: string;
|
|
215
|
+
metadata?: Record<string, string>;
|
|
216
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
paymentClient, mockConnector, MOCK_DECLINE_CARD, MOCK_3DS_CARD, IntegrationError,
|
|
4
|
+
PaymentStatus, RefundStatus, CaptureMethod, AuthenticationType, Currency,
|
|
5
|
+
type ConnectorConfig, type ConnectorRegistry, type AuthorizeRequest,
|
|
6
|
+
} from "../src/index";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* C048 — the agnostic payment interface (a TS reimplementation of the Prism schema), witnessed via the mock connector.
|
|
10
|
+
* The load-bearing properties: the status enums are Prism-integer-EXACT (a real Prism backend stays swappable), the
|
|
11
|
+
* config SELECTS the connector (switch processor without code), a decline is in-band (not thrown), and a full flow is
|
|
12
|
+
* coherent through the seam.
|
|
13
|
+
*/
|
|
14
|
+
const registry: ConnectorRegistry = { mock: mockConnector, stripe: mockConnector }; // "stripe" is the mock here (interface-first)
|
|
15
|
+
const cfg = (name: string): ConnectorConfig => ({ connectorConfig: { [name]: { apiKey: { value: "sk_test_1" } } } });
|
|
16
|
+
const card = (pan: string): AuthorizeRequest["paymentMethod"] => ({
|
|
17
|
+
card: { cardNumber: { value: pan }, cardExpMonth: { value: "12" }, cardExpYear: { value: "2030" }, cardCvc: { value: "123" } },
|
|
18
|
+
});
|
|
19
|
+
const authReq = (pan: string, over: Partial<AuthorizeRequest> = {}): AuthorizeRequest => ({
|
|
20
|
+
merchantTransactionId: "txn_1",
|
|
21
|
+
amount: { minorAmount: 1000, currency: Currency.USD },
|
|
22
|
+
captureMethod: CaptureMethod.AUTOMATIC,
|
|
23
|
+
paymentMethod: card(pan),
|
|
24
|
+
authType: AuthenticationType.NO_THREE_DS,
|
|
25
|
+
...over,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("Prism-integer-exact status semantics", () => {
|
|
29
|
+
test("PaymentStatus + RefundStatus carry Prism's exact integer values", () => {
|
|
30
|
+
expect([PaymentStatus.AUTHORIZED, PaymentStatus.CHARGED, PaymentStatus.VOIDED, PaymentStatus.FAILURE, PaymentStatus.AUTHENTICATION_PENDING]).toEqual([6, 8, 11, 21, 4]);
|
|
31
|
+
expect([RefundStatus.REFUND_PENDING, RefundStatus.REFUND_SUCCESS]).toEqual([3, 4]);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("config selects the connector (switch processor by config)", () => {
|
|
36
|
+
test("paymentClient binds the single named processor", () => {
|
|
37
|
+
expect(paymentClient(cfg("mock"), registry).name).toBe("mock");
|
|
38
|
+
expect(paymentClient(cfg("stripe"), registry).name).toBe("mock"); // same mock, chosen by a different config
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("a config naming zero, many, or an unregistered processor throws IntegrationError", () => {
|
|
42
|
+
expect(() => paymentClient({ connectorConfig: {} }, registry)).toThrow(IntegrationError);
|
|
43
|
+
expect(() => paymentClient({ connectorConfig: { mock: { apiKey: { value: "x" } }, stripe: { apiKey: { value: "y" } } } }, registry)).toThrow(/exactly one/);
|
|
44
|
+
expect(() => paymentClient(cfg("adyen"), registry)).toThrow(/no connector registered for "adyen"/);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("the unified flow through the seam", () => {
|
|
49
|
+
test("auto-capture authorize → CHARGED; manual → AUTHORIZED then capture → CHARGED", async () => {
|
|
50
|
+
const c = paymentClient(cfg("mock"), registry);
|
|
51
|
+
const auto = await c.authorize(authReq("4111111111111111"));
|
|
52
|
+
expect(auto.status).toBe(PaymentStatus.CHARGED);
|
|
53
|
+
expect(auto.connectorTransactionId).toBeString();
|
|
54
|
+
|
|
55
|
+
const manual = await c.authorize(authReq("4111111111111111", { captureMethod: CaptureMethod.MANUAL }));
|
|
56
|
+
expect(manual.status).toBe(PaymentStatus.AUTHORIZED);
|
|
57
|
+
const cap = await c.capture({ merchantCaptureId: "cap_1", connectorTransactionId: manual.connectorTransactionId!, amountToCapture: { minorAmount: 1000, currency: Currency.USD } });
|
|
58
|
+
expect(cap.status).toBe(PaymentStatus.CHARGED);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("a decline card is IN-BAND FAILURE (not a throw); 3DS → AUTHENTICATION_PENDING + redirection", async () => {
|
|
62
|
+
const c = paymentClient(cfg("mock"), registry);
|
|
63
|
+
const declined = await c.authorize(authReq(MOCK_DECLINE_CARD));
|
|
64
|
+
expect(declined.status).toBe(PaymentStatus.FAILURE);
|
|
65
|
+
expect(declined.error?.code).toBe("card_declined");
|
|
66
|
+
|
|
67
|
+
const tds = await c.authorize(authReq(MOCK_3DS_CARD, { returnUrl: "https://app/return" }));
|
|
68
|
+
expect(tds.status).toBe(PaymentStatus.AUTHENTICATION_PENDING);
|
|
69
|
+
expect(tds.redirectionData?.url).toBe("https://app/return");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("void + refund + sync are coherent across the flow", async () => {
|
|
73
|
+
const c = paymentClient(cfg("mock"), registry);
|
|
74
|
+
const auth = await c.authorize(authReq("4111111111111111", { captureMethod: CaptureMethod.MANUAL }));
|
|
75
|
+
const voided = await c.void({ merchantVoidId: "v_1", connectorTransactionId: auth.connectorTransactionId! });
|
|
76
|
+
expect(voided.status).toBe(PaymentStatus.VOIDED);
|
|
77
|
+
expect((await c.sync({ connectorTransactionId: auth.connectorTransactionId! })).status).toBe(PaymentStatus.VOIDED);
|
|
78
|
+
|
|
79
|
+
const charged = await c.authorize(authReq("4111111111111111"));
|
|
80
|
+
const refund = await c.refund({ merchantRefundId: "r_1", connectorTransactionId: charged.connectorTransactionId!, refundAmount: { minorAmount: 500, currency: Currency.USD }, paymentAmount: 1000 });
|
|
81
|
+
expect(refund.status).toBe(RefundStatus.REFUND_SUCCESS);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a connector with a bad config throws IntegrationError at bind time", () => {
|
|
85
|
+
expect(() => paymentClient({ connectorConfig: { mock: {} } }, registry)).toThrow(/needs an apiKey/);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
subtotal, orderTotal, verifyAmount, prorateDiscount, computeDiscountAmount, idempotencyKey,
|
|
4
|
+
verifyStripeSignature, webhookRouter, STRIPE_EVENTS, type CartLine, type Discount,
|
|
5
|
+
} from "../src/index";
|
|
6
|
+
|
|
7
|
+
/** C048 — the pricing + Stripe-webhook surfaces moved here from @suluk/stripe; direct coverage in their new home. */
|
|
8
|
+
|
|
9
|
+
describe("pricing primitives (moved from @suluk/stripe)", () => {
|
|
10
|
+
const lines: CartLine[] = [{ unitCents: 1999, qty: 2, id: "a" }, { unitCents: 500, qty: 1, id: "b" }];
|
|
11
|
+
|
|
12
|
+
test("verifyAmount is anti-tampering (recomputes; rejects a mismatch)", () => {
|
|
13
|
+
const total = orderTotal(lines, null).totalCents; // 4498
|
|
14
|
+
expect(verifyAmount(lines, null, total).ok).toBe(true);
|
|
15
|
+
expect(verifyAmount(lines, null, total - 1).reason).toBe("amount-mismatch");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("a discount never exceeds the subtotal; proration sums EXACTLY", () => {
|
|
19
|
+
expect(computeDiscountAmount(subtotal(lines), { type: "fixed", value: 9_999_999 } as Discount)).toBe(subtotal(lines));
|
|
20
|
+
const shares = prorateDiscount(lines, 777);
|
|
21
|
+
expect(shares.reduce((a, b) => a + b, 0)).toBe(777);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("idempotencyKey is order-independent + scope-sensitive", () => {
|
|
25
|
+
const k = idempotencyKey("u1", lines);
|
|
26
|
+
expect(idempotencyKey("u1", [...lines].reverse())).toBe(k);
|
|
27
|
+
expect(idempotencyKey("u2", lines)).not.toBe(k);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("Stripe webhook surface (moved from @suluk/stripe)", () => {
|
|
32
|
+
async function sign(secret: string, ts: number, body: string): Promise<string> {
|
|
33
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
34
|
+
const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${ts}.${body}`));
|
|
35
|
+
return [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test("verifyStripeSignature: accepts a correctly-signed fresh event; rejects tampered/stale/missing", async () => {
|
|
39
|
+
const secret = "whsec_test";
|
|
40
|
+
const body = '{"type":"payment_intent.succeeded"}';
|
|
41
|
+
const ts = 1_700_000_000;
|
|
42
|
+
const sig = await sign(secret, ts, body);
|
|
43
|
+
const now = () => ts;
|
|
44
|
+
expect(await verifyStripeSignature(body, `t=${ts},v1=${sig}`, secret, { now })).toBe(true);
|
|
45
|
+
expect(await verifyStripeSignature(`${body} `, `t=${ts},v1=${sig}`, secret, { now })).toBe(false); // body tampered
|
|
46
|
+
expect(await verifyStripeSignature(body, `t=${ts - 999},v1=${sig}`, secret, { now })).toBe(false); // stale (outside tolerance)
|
|
47
|
+
expect(await verifyStripeSignature("", "", "")).toBe(false); // missing
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("webhookRouter dispatches by type + falls back to onUnhandled", async () => {
|
|
51
|
+
const seen: string[] = [];
|
|
52
|
+
const router = webhookRouter()
|
|
53
|
+
.on(STRIPE_EVENTS.invoicePaid, () => void seen.push("paid"))
|
|
54
|
+
.onUnhandled((e) => void seen.push(`unhandled:${e.type}`));
|
|
55
|
+
expect((await router.handle({ type: STRIPE_EVENTS.invoicePaid })).handled).toBe(true);
|
|
56
|
+
expect((await router.handle({ type: "some.other" })).handled).toBe(false);
|
|
57
|
+
expect(seen).toEqual(["paid", "unhandled:some.other"]);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
stripeConnector, paymentClient, ConnectorError,
|
|
4
|
+
PaymentStatus, RefundStatus, CaptureMethod, AuthenticationType, Currency,
|
|
5
|
+
type ConnectorConfig, type AuthorizeRequest, type HttpOptions,
|
|
6
|
+
} from "../src/index";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* C048 — the built-in Stripe connector, witnessed with a MOCK fetch. The load-bearing thing is the STATUS MAPPING
|
|
10
|
+
* (Stripe PI status → the unified integer enum) + the right Stripe request; a wrong mapping mis-reports money. No live
|
|
11
|
+
* Stripe.
|
|
12
|
+
*/
|
|
13
|
+
interface Call { path: string; method: string; body: string; headers: Record<string, string> }
|
|
14
|
+
function mock(routes: Record<string, { status?: number; body: unknown }>): { http: HttpOptions; calls: Call[] } {
|
|
15
|
+
const calls: Call[] = [];
|
|
16
|
+
const fetchMock = (async (url: string | URL, init?: RequestInit) => {
|
|
17
|
+
const path = String(url).replace("https://api.stripe.com/v1/", "");
|
|
18
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
19
|
+
calls.push({ path, method, body: (init?.body as string) ?? "", headers: (init?.headers as Record<string, string>) ?? {} });
|
|
20
|
+
const key = Object.keys(routes).find((k) => `${method} ${path}`.startsWith(k));
|
|
21
|
+
const r = key ? routes[key] : { body: {} };
|
|
22
|
+
return new Response(JSON.stringify(r.body), { status: r.status ?? 200 });
|
|
23
|
+
}) as unknown as typeof fetch;
|
|
24
|
+
return { http: { fetch: fetchMock }, calls };
|
|
25
|
+
}
|
|
26
|
+
const cfg: ConnectorConfig = { connectorConfig: { stripe: { apiKey: { value: "sk_test_1" } } } };
|
|
27
|
+
const rawCard = (over: Partial<AuthorizeRequest> = {}): AuthorizeRequest => ({
|
|
28
|
+
merchantTransactionId: "txn_1",
|
|
29
|
+
amount: { minorAmount: 2000, currency: Currency.USD },
|
|
30
|
+
captureMethod: CaptureMethod.AUTOMATIC,
|
|
31
|
+
paymentMethod: { card: { cardNumber: { value: "4242424242424242" }, cardExpMonth: { value: "12" }, cardExpYear: { value: "2030" }, cardCvc: { value: "123" } } },
|
|
32
|
+
authType: AuthenticationType.NO_THREE_DS,
|
|
33
|
+
...over,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("authorize — request shape + status mapping", () => {
|
|
37
|
+
test("auto-capture success → CHARGED, with the right PI request + idempotency key", async () => {
|
|
38
|
+
const m = mock({ "POST payment_intents": { body: { id: "pi_1", status: "succeeded", amount: 2000, currency: "usd" } } });
|
|
39
|
+
const c = paymentClient(cfg, { stripe: stripeConnector }, m.http);
|
|
40
|
+
const res = await c.authorize(rawCard());
|
|
41
|
+
expect(res.status).toBe(PaymentStatus.CHARGED);
|
|
42
|
+
expect(res.connectorTransactionId).toBe("pi_1");
|
|
43
|
+
expect(res.amount).toEqual({ minorAmount: 2000, currency: "USD" });
|
|
44
|
+
const call = m.calls[0];
|
|
45
|
+
expect(call.body).toContain("amount=2000");
|
|
46
|
+
expect(call.body).toContain("currency=usd");
|
|
47
|
+
expect(call.body).toContain("confirm=true");
|
|
48
|
+
expect(call.body).toContain("payment_method_data%5Btype%5D=card");
|
|
49
|
+
expect(call.body).toContain("payment_method_data%5Bcard%5D%5Bnumber%5D=4242424242424242");
|
|
50
|
+
expect(call.headers["idempotency-key"]).toBe("auth:txn_1");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("manual capture → AUTHORIZED (requires_capture)", async () => {
|
|
54
|
+
const m = mock({ "POST payment_intents": { body: { id: "pi_2", status: "requires_capture" } } });
|
|
55
|
+
const res = await stripeConnector(cfg.connectorConfig.stripe, m.http).authorize(rawCard({ captureMethod: CaptureMethod.MANUAL }));
|
|
56
|
+
expect(res.status).toBe(PaymentStatus.AUTHORIZED);
|
|
57
|
+
expect(m.calls[0].body).toContain("capture_method=manual");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("requires_action → AUTHENTICATION_PENDING + redirection url", async () => {
|
|
61
|
+
const m = mock({ "POST payment_intents": { body: { id: "pi_3", status: "requires_action", next_action: { redirect_to_url: { url: "https://hooks.stripe.com/3ds" } } } } });
|
|
62
|
+
const res = await stripeConnector(cfg.connectorConfig.stripe, m.http).authorize(rawCard({ authType: AuthenticationType.THREE_DS, returnUrl: "https://app/return" }));
|
|
63
|
+
expect(res.status).toBe(PaymentStatus.AUTHENTICATION_PENDING);
|
|
64
|
+
expect(res.redirectionData?.url).toBe("https://hooks.stripe.com/3ds");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a card_error (402) is IN-BAND FAILURE with the decline code — not thrown", async () => {
|
|
68
|
+
const m = mock({ "POST payment_intents": { status: 402, body: { error: { type: "card_error", code: "card_declined", decline_code: "insufficient_funds", message: "Your card has insufficient funds.", payment_intent: { id: "pi_4" } } } } });
|
|
69
|
+
const res = await stripeConnector(cfg.connectorConfig.stripe, m.http).authorize(rawCard());
|
|
70
|
+
expect(res.status).toBe(PaymentStatus.FAILURE);
|
|
71
|
+
expect(res.error?.code).toBe("insufficient_funds");
|
|
72
|
+
expect(res.connectorTransactionId).toBe("pi_4");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("an off-session authentication_required (402) → AUTHENTICATION_PENDING (not thrown)", async () => {
|
|
76
|
+
const m = mock({ "POST payment_intents": { status: 402, body: { error: { type: "invalid_request_error", code: "authentication_required", payment_intent: { id: "pi_5", status: "requires_action" } } } } });
|
|
77
|
+
const res = await stripeConnector(cfg.connectorConfig.stripe, m.http).authorize(rawCard({ offSession: true, paymentMethod: { token: { value: "pm_saved" } } }));
|
|
78
|
+
expect(res.status).toBe(PaymentStatus.AUTHENTICATION_PENDING);
|
|
79
|
+
expect(res.connectorTransactionId).toBe("pi_5");
|
|
80
|
+
expect(m.calls[0].body).toContain("off_session=true");
|
|
81
|
+
expect(m.calls[0].body).toContain("payment_method=pm_saved");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a non-card hard error THROWS ConnectorError", async () => {
|
|
85
|
+
const m = mock({ "POST payment_intents": { status: 500, body: { error: { type: "api_error", message: "Stripe is down" } } } });
|
|
86
|
+
await expect(stripeConnector(cfg.connectorConfig.stripe, m.http).authorize(rawCard())).rejects.toThrow(ConnectorError);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("capture / void / refund / sync / customer", () => {
|
|
91
|
+
test("capture → CHARGED; void → VOIDED", async () => {
|
|
92
|
+
const cap = mock({ "POST payment_intents/pi_1/capture": { body: { id: "pi_1", status: "succeeded" } } });
|
|
93
|
+
expect((await stripeConnector(cfg.connectorConfig.stripe, cap.http).capture({ merchantCaptureId: "c1", connectorTransactionId: "pi_1", amountToCapture: { minorAmount: 2000, currency: Currency.USD } })).status).toBe(PaymentStatus.CHARGED);
|
|
94
|
+
const vd = mock({ "POST payment_intents/pi_1/cancel": { body: { id: "pi_1", status: "canceled" } } });
|
|
95
|
+
expect((await stripeConnector(cfg.connectorConfig.stripe, vd.http).void({ merchantVoidId: "v1", connectorTransactionId: "pi_1" })).status).toBe(PaymentStatus.VOIDED);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("refund maps succeeded→SUCCESS and pending→PENDING; passes payment_intent + amount + idempotency", async () => {
|
|
99
|
+
const ok = mock({ "POST refunds": { body: { id: "re_1", status: "succeeded" } } });
|
|
100
|
+
const r = await stripeConnector(cfg.connectorConfig.stripe, ok.http).refund({ merchantRefundId: "r1", connectorTransactionId: "pi_1", refundAmount: { minorAmount: 500, currency: Currency.USD }, paymentAmount: 2000 });
|
|
101
|
+
expect(r.status).toBe(RefundStatus.REFUND_SUCCESS);
|
|
102
|
+
expect(r.connectorRefundId).toBe("re_1");
|
|
103
|
+
expect(ok.calls[0].body).toContain("payment_intent=pi_1");
|
|
104
|
+
expect(ok.calls[0].body).toContain("amount=500");
|
|
105
|
+
expect(ok.calls[0].headers["idempotency-key"]).toBe("refund:r1");
|
|
106
|
+
|
|
107
|
+
const pend = mock({ "POST refunds": { body: { id: "re_2", status: "pending" } } });
|
|
108
|
+
expect((await stripeConnector(cfg.connectorConfig.stripe, pend.http).refund({ merchantRefundId: "r2", connectorTransactionId: "pi_1", refundAmount: { minorAmount: 500, currency: Currency.USD }, paymentAmount: 2000 })).status).toBe(RefundStatus.REFUND_PENDING);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("sync maps the live PI status; createCustomer returns the id", async () => {
|
|
112
|
+
const s = mock({ "GET payment_intents/pi_1": { body: { id: "pi_1", status: "succeeded" } } });
|
|
113
|
+
expect((await stripeConnector(cfg.connectorConfig.stripe, s.http).sync({ connectorTransactionId: "pi_1" })).status).toBe(PaymentStatus.CHARGED);
|
|
114
|
+
const cu = mock({ "POST customers": { body: { id: "cus_1" } } });
|
|
115
|
+
expect(await stripeConnector(cfg.connectorConfig.stripe, cu.http).createCustomer!({ email: "a@b.com", metadata: { userId: "u1" } })).toEqual({ customerId: "cus_1" });
|
|
116
|
+
expect(cu.calls[0].body).toContain("email=a%40b.com");
|
|
117
|
+
expect(cu.calls[0].body).toContain("metadata%5BuserId%5D=u1");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("the client-token surface (browser-confirmable sessions)", () => {
|
|
122
|
+
test("createPaymentSession (Payment Element): automatic_payment_methods + optional save; returns the client secret", async () => {
|
|
123
|
+
const m = mock({ "POST payment_intents": { body: { id: "pi_9", client_secret: "pi_9_secret" } } });
|
|
124
|
+
const s = await stripeConnector(cfg.connectorConfig.stripe, m.http).createPaymentSession!({
|
|
125
|
+
amount: { minorAmount: 2000, currency: Currency.USD }, customerId: "cus_1", setupFutureUsage: true, metadata: { userId: "u1", source: "onsite_topup" },
|
|
126
|
+
});
|
|
127
|
+
expect(s).toEqual({ clientSecret: "pi_9_secret", connectorTransactionId: "pi_9", customerId: "cus_1" });
|
|
128
|
+
const b = m.calls[0].body;
|
|
129
|
+
expect(b).toContain("automatic_payment_methods%5Benabled%5D=true");
|
|
130
|
+
expect(b).toContain("setup_future_usage=off_session");
|
|
131
|
+
expect(b).toContain("metadata%5Bsource%5D=onsite_topup");
|
|
132
|
+
expect(b).not.toContain("confirm=true"); // the BROWSER confirms — the server only creates the intent
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("createPaymentSession (one-click): pins the saved card, no automatic_payment_methods", async () => {
|
|
136
|
+
const m = mock({ "POST payment_intents": { body: { id: "pi_10", client_secret: "pi_10_secret" } } });
|
|
137
|
+
const s = await stripeConnector(cfg.connectorConfig.stripe, m.http).createPaymentSession!({
|
|
138
|
+
amount: { minorAmount: 2000, currency: Currency.USD }, customerId: "cus_1", paymentMethod: { value: "pm_1" },
|
|
139
|
+
});
|
|
140
|
+
expect(s.clientSecret).toBe("pi_10_secret");
|
|
141
|
+
expect(m.calls[0].body).toContain("payment_method=pm_1");
|
|
142
|
+
expect(m.calls[0].body).not.toContain("automatic_payment_methods");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("createSetupSession: vaults a card (usage=off_session); returns the client secret", async () => {
|
|
146
|
+
const m = mock({ "POST setup_intents": { body: { id: "seti_1", client_secret: "seti_secret" } } });
|
|
147
|
+
const s = await stripeConnector(cfg.connectorConfig.stripe, m.http).createSetupSession!({ customerId: "cus_1", metadata: { userId: "u1" } });
|
|
148
|
+
expect(s.clientSecret).toBe("seti_secret");
|
|
149
|
+
expect(m.calls[0].path).toBe("setup_intents");
|
|
150
|
+
expect(m.calls[0].body).toContain("usage=off_session");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("a session that returns no client_secret throws ConnectorError", async () => {
|
|
154
|
+
const m = mock({ "POST payment_intents": { status: 400, body: { error: { code: "amount_too_small", message: "Amount too small" } } } });
|
|
155
|
+
await expect(stripeConnector(cfg.connectorConfig.stripe, m.http).createPaymentSession!({ amount: { minorAmount: 1, currency: Currency.USD } })).rejects.toThrow(ConnectorError);
|
|
156
|
+
});
|
|
157
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["bun"] }, "include": ["src", "test"] }
|