@suluk/stripe 0.1.4 → 0.1.6
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 +1 -1
- package/src/index.ts +15 -2
- package/src/pricing.ts +17 -0
- package/src/rest.ts +76 -0
- package/src/shipping.ts +65 -0
- package/src/tax.ts +52 -0
- package/src/webhook-verify.ts +40 -0
- package/test/rest.test.ts +47 -0
- package/test/shipping-tax.test.ts +54 -0
- package/test/webhook-verify.test.ts +58 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/stripe",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "First-class Stripe via a swappable PaymentProvider: usage-based billing (Billing Meters + meter events), customers, metered prices, subscriptions, webhooks — and a bridge from @suluk/cost to Stripe usage. Other processors follow Stripe. CANDIDATE tooling.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
package/src/index.ts
CHANGED
|
@@ -11,10 +11,19 @@ export {
|
|
|
11
11
|
type UsageBillingConfig, type CostBillingConfig,
|
|
12
12
|
} from "./stripe";
|
|
13
13
|
export {
|
|
14
|
-
subtotal, computeDiscountAmount, validateDiscount, prorateDiscount, orderTotal, verifyAmount,
|
|
14
|
+
subtotal, computeDiscountAmount, validateDiscount, prorateDiscount, orderTotal, composeTotal, verifyAmount,
|
|
15
15
|
cartFingerprint, idempotencyKey, requiresStripe, STRIPE_MIN_CHARGE_CENTS,
|
|
16
|
-
type CartLine, type Discount, type DiscountResult, type DiscountRejection, type OrderTotal, type AmountVerdict,
|
|
16
|
+
type CartLine, type Discount, type DiscountResult, type DiscountRejection, type OrderTotal, type OrderTotalFull, type AmountVerdict,
|
|
17
17
|
} from "./pricing";
|
|
18
|
+
// pluggable shipping + tax adapters — swap flat-rate for Shippo/EasyPost/TaxJar/Stripe-Tax without touching checkout.
|
|
19
|
+
export {
|
|
20
|
+
cartNeedsShipping, flatRateShipping, combineShipping, resolveShipping,
|
|
21
|
+
type ShippingInput, type ShippingOption, type ShippingProvider,
|
|
22
|
+
} from "./shipping";
|
|
23
|
+
export {
|
|
24
|
+
flatRateTax, noTax, resolveTax,
|
|
25
|
+
type TaxInput, type TaxResult, type TaxProvider,
|
|
26
|
+
} from "./tax";
|
|
18
27
|
// the checkout money-path (Phase 1): the pure anti-double-charge / anti-tampering core + the Stripe binding.
|
|
19
28
|
export {
|
|
20
29
|
planPaymentIntent, cardInfoFrom, ownsPaymentMethod, stripeCheckout,
|
|
@@ -22,3 +31,7 @@ export {
|
|
|
22
31
|
type StripeCheckoutLike, type PaymentMethodLike, type PaymentIntentLike,
|
|
23
32
|
} from "./checkout";
|
|
24
33
|
export { webhookRouter, STRIPE_EVENTS, type WebhookRouter, type WebhookHandler, type HandleResult } from "./webhook";
|
|
34
|
+
// SDK-free edge webhook verification (Web Crypto) — one verifier for dev + Workers prod, no `stripe` SDK.
|
|
35
|
+
export { verifyStripeSignature, timingSafeHexEqual, type VerifyOptions } from "./webhook-verify";
|
|
36
|
+
// the StripeLike fetch impl (Workers-safe) + read surface for webhook PI→order resolution + refund checks.
|
|
37
|
+
export { restStripe, toForm, stripeGet, retrievePaymentIntent, type RestStripeOptions } from "./rest";
|
package/src/pricing.ts
CHANGED
|
@@ -112,6 +112,23 @@ export function orderTotal(lines: CartLine[], discount?: Discount | null): Order
|
|
|
112
112
|
return { subtotalCents, discountCents, totalCents: subtotalCents - discountCents };
|
|
113
113
|
}
|
|
114
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
|
+
|
|
115
132
|
/**
|
|
116
133
|
* ANTI-TAMPERING: recompute the total from authoritative line prices + the discount and compare it to the amount
|
|
117
134
|
* the client claims (e.g. a PaymentIntent amount the browser posted). Reject any mismatch beyond `toleranceCents`
|
package/src/rest.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A {@link StripeLike} client over the Stripe REST API (fetch only — no SDK), so the @suluk/stripe helpers
|
|
3
|
+
* (setupUsageBilling / stripeProvider / reportCostUsage / subscriptions) run on a Cloudflare Worker or any fetch
|
|
4
|
+
* runtime. Stripe's API is form-encoded with bracket notation for nested params; {@link toForm} flattens the param
|
|
5
|
+
* objects the helpers build. Plus the read surface ({@link stripeGet} / {@link retrievePaymentIntent}) the edge needs
|
|
6
|
+
* for webhook PaymentIntent→order resolution and refund-state checks. `fetch` is injectable for testing.
|
|
7
|
+
*/
|
|
8
|
+
import type { StripeLike } from "./types";
|
|
9
|
+
|
|
10
|
+
type FetchLike = typeof fetch;
|
|
11
|
+
export interface RestStripeOptions { fetch?: FetchLike }
|
|
12
|
+
|
|
13
|
+
/** Flatten a params object into Stripe's bracket form-encoding (recurse objects + arrays). */
|
|
14
|
+
export function toForm(obj: Record<string, unknown>, prefix = "", form = new URLSearchParams()): URLSearchParams {
|
|
15
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
16
|
+
if (v == null) continue;
|
|
17
|
+
const key = prefix ? `${prefix}[${k}]` : k;
|
|
18
|
+
if (Array.isArray(v)) {
|
|
19
|
+
v.forEach((item, i) => {
|
|
20
|
+
if (item && typeof item === "object") toForm(item as Record<string, unknown>, `${key}[${i}]`, form);
|
|
21
|
+
else form.append(`${key}[${i}]`, String(item));
|
|
22
|
+
});
|
|
23
|
+
} else if (typeof v === "object") {
|
|
24
|
+
toForm(v as Record<string, unknown>, key, form);
|
|
25
|
+
} else {
|
|
26
|
+
form.append(key, String(v));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return form;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A duck-typed Stripe client backed by the REST API. `key` is the secret key. */
|
|
33
|
+
export function restStripe(key: string, opts: RestStripeOptions = {}): StripeLike {
|
|
34
|
+
const f = opts.fetch ?? fetch;
|
|
35
|
+
const post = async (path: string, params: Record<string, unknown>): Promise<any> => { // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
36
|
+
const res = await f(`https://api.stripe.com/v1/${path}`, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: { authorization: `Bearer ${key}`, "content-type": "application/x-www-form-urlencoded" },
|
|
39
|
+
body: toForm(params).toString(),
|
|
40
|
+
});
|
|
41
|
+
const json = await res.json().catch(() => ({}));
|
|
42
|
+
if (!res.ok) throw new Error((json as { error?: { message?: string } })?.error?.message ?? `Stripe ${path} failed (${res.status})`);
|
|
43
|
+
return json;
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
customers: { create: (p) => post("customers", p) },
|
|
47
|
+
products: { create: (p) => post("products", p) },
|
|
48
|
+
prices: { create: (p) => post("prices", p) },
|
|
49
|
+
subscriptions: { create: (p) => post("subscriptions", p) },
|
|
50
|
+
billing: {
|
|
51
|
+
meters: { create: (p) => post("billing/meters", p) },
|
|
52
|
+
meterEvents: { create: (p) => post("billing/meter_events", p) },
|
|
53
|
+
},
|
|
54
|
+
billingPortal: { sessions: { create: (p) => post("billing_portal/sessions", p) } },
|
|
55
|
+
// verifyWebhook isn't used by the REST adapter (the edge uses verifyStripeSignature) — stub to satisfy the type.
|
|
56
|
+
webhooks: { constructEvent: () => { throw new Error("constructEvent not supported by the REST adapter; use verifyStripeSignature"); } },
|
|
57
|
+
} as StripeLike;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Low-level authenticated GET → parsed JSON (null on non-OK / parse error). */
|
|
61
|
+
export async function stripeGet<T = unknown>(key: string, path: string, opts: RestStripeOptions = {}): Promise<T | null> {
|
|
62
|
+
if (!key) return null;
|
|
63
|
+
const f = opts.fetch ?? fetch;
|
|
64
|
+
try {
|
|
65
|
+
const r = await f(`https://api.stripe.com/v1/${path}`, { headers: { authorization: `Bearer ${key}` } });
|
|
66
|
+
if (!r.ok) return null;
|
|
67
|
+
return (await r.json().catch(() => null)) as T | null;
|
|
68
|
+
} catch { return null; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Retrieve a PaymentIntent, optionally expanding fields (e.g. `latest_charge`). Returns null if unresolvable. */
|
|
72
|
+
export async function retrievePaymentIntent<T = Record<string, unknown>>(key: string, id: string, opts: RestStripeOptions & { expand?: string[] } = {}): Promise<T | null> {
|
|
73
|
+
if (!id) return null;
|
|
74
|
+
const q = (opts.expand ?? []).map((e) => `expand[]=${encodeURIComponent(e)}`).join("&");
|
|
75
|
+
return stripeGet<T>(key, `payment_intents/${encodeURIComponent(id)}${q ? "?" + q : ""}`, opts);
|
|
76
|
+
}
|
package/src/shipping.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SHIPPING — a pluggable rate-quoting adapter, so a store can swap or stack shipping providers (flat-rate now;
|
|
3
|
+
* Shippo / EasyPost / Stripe-Shipping / carrier-specific later) WITHOUT touching checkout. Like {@link ./pricing},
|
|
4
|
+
* this is pure MATH + structure; "which provider, what rates" is config the app supplies. All money is integer cents.
|
|
5
|
+
*
|
|
6
|
+
* The contract: a {@link ShippingProvider} turns a {@link ShippingInput} (the priced cart + an optional destination)
|
|
7
|
+
* into zero-or-more {@link ShippingOption}s the buyer can pick. A DIGITAL-only cart quotes nothing (free, no method).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** What a provider sees: the priced cart, which lines physically ship, and an optional destination. */
|
|
11
|
+
export interface ShippingInput {
|
|
12
|
+
subtotalCents: number;
|
|
13
|
+
/** one entry per cart line; `requiresShipping` marks a physical good (absent/false ⇒ digital, never shipped). */
|
|
14
|
+
lines: { id?: string | number; qty: number; requiresShipping?: boolean }[];
|
|
15
|
+
address?: { country?: string; state?: string; postalCode?: string };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A quoted shipping method the buyer can choose. `amountCents` is integer minor units. */
|
|
19
|
+
export interface ShippingOption { id: string; label: string; amountCents: number; estimate?: string }
|
|
20
|
+
|
|
21
|
+
/** A swappable shipping-rate source. Implement `quote` over any carrier API; the default is {@link flatRateShipping}. */
|
|
22
|
+
export interface ShippingProvider { id: string; quote(input: ShippingInput): ShippingOption[] | Promise<ShippingOption[]> }
|
|
23
|
+
|
|
24
|
+
const cents = (n: number) => Math.max(0, Math.round(Number.isFinite(n) ? n : 0));
|
|
25
|
+
|
|
26
|
+
/** Does the cart contain anything that physically ships? (A digital-only cart needs no shipping at all.) */
|
|
27
|
+
export function cartNeedsShipping(input: ShippingInput): boolean {
|
|
28
|
+
return (input.lines ?? []).some((l) => l && l.requiresShipping);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The default provider: a single FLAT fee, optionally FREE over a subtotal threshold ("free shipping over $50").
|
|
33
|
+
* Quotes nothing for a digital-only cart, so digital orders stay free + method-less.
|
|
34
|
+
*/
|
|
35
|
+
export function flatRateShipping(opts: { flatCents: number; freeOverCents?: number; label?: string; id?: string }): ShippingProvider {
|
|
36
|
+
const id = opts.id ?? "flat";
|
|
37
|
+
return {
|
|
38
|
+
id,
|
|
39
|
+
quote(input) {
|
|
40
|
+
if (!cartNeedsShipping(input)) return [];
|
|
41
|
+
const free = opts.freeOverCents != null && cents(input.subtotalCents) >= cents(opts.freeOverCents);
|
|
42
|
+
return [{ id, label: opts.label ?? "Standard shipping", amountCents: free ? 0 : cents(opts.flatCents), estimate: "3–7 business days" }];
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Compose several providers — the union of every provider's options (e.g. flat + an express carrier). */
|
|
48
|
+
export function combineShipping(...providers: ShippingProvider[]): ShippingProvider {
|
|
49
|
+
return {
|
|
50
|
+
id: "combined",
|
|
51
|
+
async quote(input) { return (await Promise.all(providers.map((p) => p.quote(input)))).flat(); },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the buyer's CHOSEN option from a provider's quote — by id when given, else the cheapest. Returns null when
|
|
57
|
+
* the cart needs no shipping (digital-only) so the caller adds a $0 / no-method line. The server must re-resolve
|
|
58
|
+
* (never trust a client-sent amount) — pass only the chosen id.
|
|
59
|
+
*/
|
|
60
|
+
export async function resolveShipping(provider: ShippingProvider | null | undefined, input: ShippingInput, chosenId?: string): Promise<ShippingOption | null> {
|
|
61
|
+
if (!provider) return null;
|
|
62
|
+
const options = await provider.quote(input);
|
|
63
|
+
if (!options.length) return null;
|
|
64
|
+
return (chosenId ? options.find((o) => o.id === chosenId) : undefined) ?? options.slice().sort((a, b) => a.amountCents - b.amountCents)[0];
|
|
65
|
+
}
|
package/src/tax.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TAX — a pluggable tax-rule adapter, so a store can swap or layer tax logic (a flat rate now; TaxJar / Stripe Tax /
|
|
3
|
+
* jurisdiction tables / per-product rules later) WITHOUT touching checkout. Pure MATH + structure; the RULES (which
|
|
4
|
+
* rate, where, what's taxable) are config the app supplies. All money is integer cents.
|
|
5
|
+
*
|
|
6
|
+
* The contract: a {@link TaxProvider} turns a {@link TaxInput} (the taxable base + destination + lines) into a
|
|
7
|
+
* {@link TaxResult} (cents of tax, the effective rate, a label). Real tax is jurisdiction-specific — the default
|
|
8
|
+
* {@link flatRateTax} is a clearly-labeled starter placeholder; production swaps in a real provider via the same shape.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** What a tax provider sees. `subtotalCents` is the TAXABLE base (typically post-discount). */
|
|
12
|
+
export interface TaxInput {
|
|
13
|
+
subtotalCents: number;
|
|
14
|
+
shippingCents?: number;
|
|
15
|
+
address?: { country?: string; state?: string; postalCode?: string };
|
|
16
|
+
/** per-line, when a provider needs item-level taxability (e.g. digital vs physical, exempt categories). */
|
|
17
|
+
lines?: { id?: string | number; qty: number; taxable?: boolean }[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The computed tax. `rate` is the effective fraction (0.08 = 8%); `label` shows on the order summary. */
|
|
21
|
+
export interface TaxResult { taxCents: number; rate?: number; label?: string }
|
|
22
|
+
|
|
23
|
+
/** A swappable tax-rule source. Implement `calculate` over any rules engine; the default is {@link flatRateTax}. */
|
|
24
|
+
export interface TaxProvider { id: string; calculate(input: TaxInput): TaxResult | Promise<TaxResult> }
|
|
25
|
+
|
|
26
|
+
const cents = (n: number) => Math.max(0, Math.round(Number.isFinite(n) ? n : 0));
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The default provider: a single FLAT rate on the taxable base (optionally including shipping). A starter placeholder
|
|
30
|
+
* — swap for a jurisdiction-aware provider in production. `rate` is a fraction (0.08 = 8%); a non-positive rate ⇒ $0.
|
|
31
|
+
*/
|
|
32
|
+
export function flatRateTax(opts: { rate: number; label?: string; taxableShipping?: boolean; id?: string }): TaxProvider {
|
|
33
|
+
return {
|
|
34
|
+
id: opts.id ?? "flat",
|
|
35
|
+
calculate(input) {
|
|
36
|
+
const rate = Number.isFinite(opts.rate) && opts.rate > 0 ? opts.rate : 0;
|
|
37
|
+
const base = cents(input.subtotalCents) + (opts.taxableShipping ? cents(input.shippingCents ?? 0) : 0);
|
|
38
|
+
return { taxCents: Math.round(base * rate), rate, label: opts.label ?? "Tax" };
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A no-op provider — tax-exempt, or handled externally (e.g. Stripe Tax computes it on the PaymentIntent). */
|
|
44
|
+
export function noTax(): TaxProvider {
|
|
45
|
+
return { id: "none", calculate: () => ({ taxCents: 0 }) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Resolve tax via the provider (or $0 when none configured). The server must compute this — never trust the client. */
|
|
49
|
+
export async function resolveTax(provider: TaxProvider | null | undefined, input: TaxInput): Promise<TaxResult> {
|
|
50
|
+
if (!provider) return { taxCents: 0 };
|
|
51
|
+
return provider.calculate(input);
|
|
52
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SDK-free Stripe webhook signature verification (Web Crypto HMAC-SHA256) — runs on Workers / Bun / Node 18+ with
|
|
3
|
+
* NO `stripe` SDK. The edge leaf: prove a webhook payload is authentic AND fresh before dispatching it (e.g. via
|
|
4
|
+
* {@link webhookRouter}). `stripeProvider.verifyWebhook` (the SDK's constructEvent) stays for SDK callers; this is
|
|
5
|
+
* the binding-free path, so a dev server and a Workers prod runtime can share ONE verifier instead of diverging.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Constant-time hex-string compare (no early-out) — guards the signature check against timing oracles. */
|
|
9
|
+
export function timingSafeHexEqual(a: string, b: string): boolean {
|
|
10
|
+
if (a.length !== b.length) return false;
|
|
11
|
+
let diff = 0;
|
|
12
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
13
|
+
return diff === 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface VerifyOptions {
|
|
17
|
+
/** current unix seconds (default `Date.now()/1000`) — injectable for tests + replay-window tuning. */
|
|
18
|
+
now?: () => number;
|
|
19
|
+
/** reject events whose timestamp is older than this many seconds (default 300 — Stripe's window). */
|
|
20
|
+
toleranceSec?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Verify a Stripe `stripe-signature` header against the raw request body + the endpoint signing secret.
|
|
25
|
+
* Returns true iff a v1 signature matches the HMAC of `${t}.${rawBody}` AND the timestamp is within tolerance.
|
|
26
|
+
* Pass the RAW (unparsed) body — re-serializing JSON changes the bytes and breaks the HMAC.
|
|
27
|
+
*/
|
|
28
|
+
export async function verifyStripeSignature(rawBody: string, sigHeader: string, secret: string, opts: VerifyOptions = {}): Promise<boolean> {
|
|
29
|
+
if (!rawBody || !sigHeader || !secret) return false;
|
|
30
|
+
const parts: Record<string, string> = {};
|
|
31
|
+
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
|
|
32
|
+
const ts = Number(parts.t);
|
|
33
|
+
if (!parts.t || !parts.v1 || !Number.isFinite(ts)) return false;
|
|
34
|
+
const now = (opts.now ?? (() => Date.now() / 1000))();
|
|
35
|
+
if (Math.abs(now - ts) > (opts.toleranceSec ?? 300)) return false; // reject stale/replayed events (Stripe's 5-min window)
|
|
36
|
+
const keyData = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
37
|
+
const mac = await crypto.subtle.sign("HMAC", keyData, new TextEncoder().encode(`${ts}.${rawBody}`));
|
|
38
|
+
const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
39
|
+
return timingSafeHexEqual(expected, parts.v1);
|
|
40
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rest.ts — the Workers-safe StripeLike fetch impl. Pins toForm's bracket encoding (the load-bearing form-encoder
|
|
3
|
+
* the helpers rely on) + the read surface (retrievePaymentIntent) over an injected fetch.
|
|
4
|
+
*/
|
|
5
|
+
import { test, expect, describe } from "bun:test";
|
|
6
|
+
import { toForm, restStripe, retrievePaymentIntent, stripeGet } from "../src/index";
|
|
7
|
+
|
|
8
|
+
describe("toForm", () => {
|
|
9
|
+
test("flattens scalars, nested objects, and arrays into Stripe bracket notation", () => {
|
|
10
|
+
const f = toForm({ email: "a@b.com", metadata: { orderId: 7 }, items: [{ price: "p_1", qty: 2 }] });
|
|
11
|
+
expect(f.get("email")).toBe("a@b.com");
|
|
12
|
+
expect(f.get("metadata[orderId]")).toBe("7");
|
|
13
|
+
expect(f.get("items[0][price]")).toBe("p_1");
|
|
14
|
+
expect(f.get("items[0][qty]")).toBe("2");
|
|
15
|
+
});
|
|
16
|
+
test("drops null/undefined", () => {
|
|
17
|
+
const f = toForm({ a: 1, b: null, c: undefined });
|
|
18
|
+
expect(f.has("b")).toBe(false);
|
|
19
|
+
expect(f.has("c")).toBe(false);
|
|
20
|
+
expect(f.get("a")).toBe("1");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("rest read surface (injected fetch)", () => {
|
|
25
|
+
const mockFetch = (status: number, body: unknown) => (async () => new Response(JSON.stringify(body), { status })) as unknown as typeof fetch;
|
|
26
|
+
|
|
27
|
+
test("retrievePaymentIntent returns the parsed PI + builds the expand query", async () => {
|
|
28
|
+
let seenUrl = "";
|
|
29
|
+
const fetch = (async (url: string) => { seenUrl = url; return new Response(JSON.stringify({ id: "pi_1", metadata: { orderId: "42" } }), { status: 200 }); }) as unknown as typeof fetch;
|
|
30
|
+
const pi = await retrievePaymentIntent<{ metadata?: { orderId?: string } }>("sk_test", "pi_1", { expand: ["latest_charge"], fetch });
|
|
31
|
+
expect(pi?.metadata?.orderId).toBe("42");
|
|
32
|
+
expect(seenUrl).toContain("/payment_intents/pi_1");
|
|
33
|
+
expect(seenUrl).toContain("expand[]=latest_charge");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("stripeGet returns null on non-OK", async () => {
|
|
37
|
+
expect(await stripeGet("sk_test", "payment_intents/x", { fetch: mockFetch(404, { error: {} }) })).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("restStripe.customers.create posts form-encoded + parses the result", async () => {
|
|
41
|
+
let body = "";
|
|
42
|
+
const fetch = (async (_url: string, init: RequestInit) => { body = String(init.body); return new Response(JSON.stringify({ id: "cus_1" }), { status: 200 }); }) as unknown as typeof fetch;
|
|
43
|
+
const r = await restStripe("sk_test", { fetch }).customers.create({ email: "a@b.com" });
|
|
44
|
+
expect((r as { id: string }).id).toBe("cus_1");
|
|
45
|
+
expect(body).toContain("email=a%40b.com");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
cartNeedsShipping, flatRateShipping, combineShipping, resolveShipping,
|
|
4
|
+
flatRateTax, noTax, resolveTax, composeTotal,
|
|
5
|
+
} from "../src/index";
|
|
6
|
+
|
|
7
|
+
const physical = (subtotalCents: number) => ({ subtotalCents, lines: [{ qty: 1, requiresShipping: true }] });
|
|
8
|
+
const digital = (subtotalCents: number) => ({ subtotalCents, lines: [{ qty: 1, requiresShipping: false }] });
|
|
9
|
+
|
|
10
|
+
test("flat shipping: flat fee for a physical cart under the free threshold", async () => {
|
|
11
|
+
const p = flatRateShipping({ flatCents: 500, freeOverCents: 5000 });
|
|
12
|
+
const o = await resolveShipping(p, physical(2900));
|
|
13
|
+
expect(o?.amountCents).toBe(500);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("flat shipping: FREE over the threshold", async () => {
|
|
17
|
+
const p = flatRateShipping({ flatCents: 500, freeOverCents: 5000 });
|
|
18
|
+
expect((await resolveShipping(p, physical(5000)))?.amountCents).toBe(0);
|
|
19
|
+
expect((await resolveShipping(p, physical(9900)))?.amountCents).toBe(0);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("flat shipping: digital-only cart quotes nothing", async () => {
|
|
23
|
+
const p = flatRateShipping({ flatCents: 500 });
|
|
24
|
+
expect(cartNeedsShipping(digital(9900))).toBe(false);
|
|
25
|
+
expect(await resolveShipping(p, digital(9900))).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("resolveShipping picks the chosen id, else the cheapest", async () => {
|
|
29
|
+
const p = combineShipping(
|
|
30
|
+
flatRateShipping({ flatCents: 500, id: "std", label: "Standard" }),
|
|
31
|
+
flatRateShipping({ flatCents: 1500, id: "exp", label: "Express" }),
|
|
32
|
+
);
|
|
33
|
+
expect((await resolveShipping(p, physical(2900)))?.id).toBe("std"); // cheapest by default
|
|
34
|
+
expect((await resolveShipping(p, physical(2900), "exp"))?.amountCents).toBe(1500); // honor the choice
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("flat tax: rate on the taxable base; shipping taxable only when opted in", async () => {
|
|
38
|
+
expect((await resolveTax(flatRateTax({ rate: 0.08 }), { subtotalCents: 2900 })).taxCents).toBe(232);
|
|
39
|
+
// taxableShipping folds shipping into the base
|
|
40
|
+
expect((await resolveTax(flatRateTax({ rate: 0.1, taxableShipping: true }), { subtotalCents: 1000, shippingCents: 500 })).taxCents).toBe(150);
|
|
41
|
+
expect((await resolveTax(flatRateTax({ rate: 0.1 }), { subtotalCents: 1000, shippingCents: 500 })).taxCents).toBe(100);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("noTax + a non-positive rate yield zero", async () => {
|
|
45
|
+
expect((await resolveTax(noTax(), { subtotalCents: 9999 })).taxCents).toBe(0);
|
|
46
|
+
expect((await resolveTax(flatRateTax({ rate: 0 }), { subtotalCents: 9999 })).taxCents).toBe(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("composeTotal folds subtotal − discount + shipping + tax, clamped", () => {
|
|
50
|
+
expect(composeTotal({ subtotalCents: 2900, discountCents: 400, shippingCents: 500, taxCents: 200 }).totalCents).toBe(3200);
|
|
51
|
+
// discount never exceeds subtotal; negatives clamp to 0
|
|
52
|
+
expect(composeTotal({ subtotalCents: 1000, discountCents: 5000 }).totalCents).toBe(0);
|
|
53
|
+
expect(composeTotal({ subtotalCents: 1000, shippingCents: -50, taxCents: NaN }).totalCents).toBe(1000);
|
|
54
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* verifyStripeSignature — the SDK-free Web-Crypto webhook verifier. Pins a known-good signature (computed with the
|
|
3
|
+
* same HMAC the verifier checks), a stale-timestamp rejection, and a tampered-body rejection. The constant-time
|
|
4
|
+
* compare must have no early-out. These pin the behavior BEFORE dev + prod adopt this one verifier.
|
|
5
|
+
*/
|
|
6
|
+
import { test, expect, describe } from "bun:test";
|
|
7
|
+
import { verifyStripeSignature, timingSafeHexEqual } from "../src/index";
|
|
8
|
+
|
|
9
|
+
const SECRET = "whsec_test_secret";
|
|
10
|
+
const BODY = JSON.stringify({ id: "evt_1", type: "checkout.session.completed", data: { object: { id: "cs_1" } } });
|
|
11
|
+
|
|
12
|
+
/** Build a valid `stripe-signature` header (t=<ts>,v1=<HMAC-SHA256(`${ts}.${body}`)>) the way Stripe does. */
|
|
13
|
+
async function sign(body: string, secret: string, ts: number): Promise<string> {
|
|
14
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
15
|
+
const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${ts}.${body}`));
|
|
16
|
+
const v1 = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
17
|
+
return `t=${ts},v1=${v1}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("verifyStripeSignature", () => {
|
|
21
|
+
const now = () => 1_700_000_000; // fixed clock
|
|
22
|
+
|
|
23
|
+
test("accepts a fresh, correctly-signed payload", async () => {
|
|
24
|
+
const sig = await sign(BODY, SECRET, 1_700_000_000);
|
|
25
|
+
expect(await verifyStripeSignature(BODY, sig, SECRET, { now })).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("rejects a stale timestamp beyond tolerance (replay)", async () => {
|
|
29
|
+
const sig = await sign(BODY, SECRET, 1_700_000_000 - 600); // 10 min old, tolerance 300
|
|
30
|
+
expect(await verifyStripeSignature(BODY, sig, SECRET, { now })).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("rejects a tampered body (HMAC mismatch)", async () => {
|
|
34
|
+
const sig = await sign(BODY, SECRET, 1_700_000_000);
|
|
35
|
+
expect(await verifyStripeSignature(BODY + " ", sig, SECRET, { now })).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("rejects a wrong secret, a missing v1, and empty inputs", async () => {
|
|
39
|
+
const sig = await sign(BODY, SECRET, 1_700_000_000);
|
|
40
|
+
expect(await verifyStripeSignature(BODY, sig, "whsec_wrong", { now })).toBe(false);
|
|
41
|
+
expect(await verifyStripeSignature(BODY, "t=1700000000", SECRET, { now })).toBe(false);
|
|
42
|
+
expect(await verifyStripeSignature("", sig, SECRET, { now })).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("honors an injected tolerance window", async () => {
|
|
46
|
+
const sig = await sign(BODY, SECRET, 1_700_000_000 - 60);
|
|
47
|
+
expect(await verifyStripeSignature(BODY, sig, SECRET, { now, toleranceSec: 30 })).toBe(false);
|
|
48
|
+
expect(await verifyStripeSignature(BODY, sig, SECRET, { now, toleranceSec: 120 })).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("timingSafeHexEqual", () => {
|
|
53
|
+
test("true on equal, false on differing same-length and differing-length", () => {
|
|
54
|
+
expect(timingSafeHexEqual("deadbeef", "deadbeef")).toBe(true);
|
|
55
|
+
expect(timingSafeHexEqual("deadbeef", "deadbef0")).toBe(false);
|
|
56
|
+
expect(timingSafeHexEqual("dead", "deadbeef")).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
});
|