@suluk/stripe 0.1.1 → 0.1.3
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/README.md +35 -0
- package/package.json +17 -6
- package/src/checkout.ts +167 -0
- package/src/index.ts +13 -1
- package/src/pricing.ts +150 -0
- package/src/stripe.ts +11 -0
- package/src/types.ts +4 -0
- package/src/webhook.ts +51 -0
- package/test/checkout.test.ts +128 -0
- package/test/pricing.test.ts +150 -0
- package/test/stripe.test.ts +8 -1
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://github.com/MahmoodKhalil57/suluk">
|
|
3
|
+
<img src="https://raw.githubusercontent.com/MahmoodKhalil57/suluk/main/branding/export/wordmark.png" alt="Suluk" width="360" />
|
|
4
|
+
</a>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<h1 align="center">@suluk/stripe</h1>
|
|
8
|
+
|
|
9
|
+
<p align="center"><b>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.</b></p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<em>Part of <a href="https://github.com/MahmoodKhalil57/suluk">Suluk</a> — one typed OpenAPI v4 contract projecting into every full-stack layer.</em>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
> **CANDIDATE tooling — not official OpenAPI.** Suluk is a single-contributor candidate for
|
|
18
|
+
> OpenAPI Specification v4.0 ("Moonwalk"), unaffiliated with the OpenAPI Initiative and unable
|
|
19
|
+
> to ratify anything on the SIG's behalf.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
bun add @suluk/stripe
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## The Suluk cycle
|
|
28
|
+
|
|
29
|
+
`@suluk/stripe` is one station on the Suluk walk — author one v4 source, then **validate · audit ·
|
|
30
|
+
preview · generate · deploy** the whole stack from it. Explore the full toolchain in the
|
|
31
|
+
[main repository](https://github.com/MahmoodKhalil57/suluk) or drive it from the [VS Code cockpit](https://marketplace.visualstudio.com/items?itemName=MahmoodKhalil.suluk-vscode).
|
|
32
|
+
|
|
33
|
+
## License
|
|
34
|
+
|
|
35
|
+
Apache-2.0
|
package/package.json
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/stripe",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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
|
-
"
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
|
|
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/stripe"
|
|
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"
|
|
9
20
|
},
|
|
10
21
|
"dependencies": {
|
|
11
|
-
"@suluk/cost": "0.1.
|
|
22
|
+
"@suluk/cost": "^0.1.2"
|
|
12
23
|
},
|
|
13
24
|
"peerDependencies": {
|
|
14
25
|
"stripe": "^17.0.0 || ^18.0.0"
|
package/src/checkout.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The checkout money-path (saastarter-parity Phase 1; PARITY §2 trust layer). Distinct from usage billing
|
|
3
|
+
* (stripe.ts): one-time checkout — payment intents, the saved-card vault, customer find-or-create. The TRUST core
|
|
4
|
+
* is PURE and lives here (never charge a client-supplied amount; reuse a matching intent so a retry can't
|
|
5
|
+
* double-charge; re-price on a cart change; thread a deterministic idempotency key) — built on the Phase-0 pricing
|
|
6
|
+
* primitives. The Stripe API calls are a SWAPPABLE binding (`StripeCheckoutLike`), satisfied by the real SDK or a mock.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
orderTotal, verifyAmount, idempotencyKey,
|
|
10
|
+
type CartLine, type Discount, type AmountVerdict,
|
|
11
|
+
} from "./pricing";
|
|
12
|
+
|
|
13
|
+
/** A saved card, surfaced to the account/checkout UI (saastarter CardInfo, services/stripe.ts:13). */
|
|
14
|
+
export interface CardInfo {
|
|
15
|
+
id: string;
|
|
16
|
+
brand?: string;
|
|
17
|
+
last4?: string;
|
|
18
|
+
expMonth?: number;
|
|
19
|
+
expYear?: number;
|
|
20
|
+
isDefault: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A Stripe PaymentMethod (the slice we read). */
|
|
24
|
+
export interface PaymentMethodLike {
|
|
25
|
+
id: string;
|
|
26
|
+
customer?: string | null;
|
|
27
|
+
card?: { brand?: string; last4?: string; exp_month?: number; exp_year?: number } | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A Stripe PaymentIntent (the slice we read). */
|
|
31
|
+
export interface PaymentIntentLike {
|
|
32
|
+
id: string;
|
|
33
|
+
amount: number;
|
|
34
|
+
currency: string;
|
|
35
|
+
status?: string;
|
|
36
|
+
client_secret?: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The minimal Stripe checkout surface this module calls — satisfied by the real `stripe` SDK and by mocks. */
|
|
40
|
+
export interface StripeCheckoutLike {
|
|
41
|
+
customers: {
|
|
42
|
+
create(params: Record<string, unknown>): Promise<{ id: string }>;
|
|
43
|
+
list(params: { email: string; limit?: number }): Promise<{ data: { id: string }[] }>;
|
|
44
|
+
};
|
|
45
|
+
paymentIntents: {
|
|
46
|
+
create(params: Record<string, unknown>, opts?: { idempotencyKey?: string }): Promise<PaymentIntentLike>;
|
|
47
|
+
retrieve(id: string): Promise<PaymentIntentLike>;
|
|
48
|
+
update(id: string, params: Record<string, unknown>): Promise<PaymentIntentLike>;
|
|
49
|
+
};
|
|
50
|
+
setupIntents: { create(params: Record<string, unknown>): Promise<{ client_secret: string | null }> };
|
|
51
|
+
paymentMethods: {
|
|
52
|
+
list(params: { customer: string; type: string }): Promise<{ data: PaymentMethodLike[] }>;
|
|
53
|
+
retrieve(id: string): Promise<PaymentMethodLike>;
|
|
54
|
+
detach(id: string): Promise<PaymentMethodLike>;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── the PURE trust core ───────────────────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
export interface IntentPlan {
|
|
61
|
+
/** create a new intent · reuse the matching one (no charge change) · update an existing one to a new total. */
|
|
62
|
+
action: "create" | "reuse" | "update";
|
|
63
|
+
/** the AUTHORITATIVE amount, recomputed from line prices — never a client-supplied number. */
|
|
64
|
+
amountCents: number;
|
|
65
|
+
currency: string;
|
|
66
|
+
/** deterministic key (cart × scope) — threaded into create so a retry reuses one intent (no double-charge). */
|
|
67
|
+
idempotencyKey: string;
|
|
68
|
+
/** the intent to reuse/update (absent for create). */
|
|
69
|
+
intentId?: string;
|
|
70
|
+
/** when a client claimed an amount, the anti-tampering verdict (the caller MUST reject `ok:false`). */
|
|
71
|
+
amountVerdict?: AmountVerdict;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Decide what to do with a checkout's payment intent — the anti-double-charge / anti-tampering decision, pure and
|
|
76
|
+
* fully testable. Recomputes the total from authoritative prices; reuses an existing intent iff its amount already
|
|
77
|
+
* matches (idempotent retry); updates it when the cart changed; creates one otherwise. If `claimedCents` is given,
|
|
78
|
+
* attaches the verifyAmount verdict so the caller can reject a tampered amount BEFORE touching Stripe.
|
|
79
|
+
*/
|
|
80
|
+
export function planPaymentIntent(input: {
|
|
81
|
+
lines: CartLine[];
|
|
82
|
+
discount?: Discount | null;
|
|
83
|
+
/** principal/session id — namespaces the idempotency key so two users' identical carts don't collide. */
|
|
84
|
+
scope: string;
|
|
85
|
+
currency: string;
|
|
86
|
+
existingIntent?: { id: string; amountCents: number } | null;
|
|
87
|
+
claimedCents?: number;
|
|
88
|
+
}): IntentPlan {
|
|
89
|
+
const discount = input.discount ?? null;
|
|
90
|
+
const amountCents = orderTotal(input.lines, discount).totalCents;
|
|
91
|
+
const key = idempotencyKey(input.scope, input.lines, discount);
|
|
92
|
+
const amountVerdict = input.claimedCents != null ? verifyAmount(input.lines, discount, input.claimedCents) : undefined;
|
|
93
|
+
let action: IntentPlan["action"] = "create";
|
|
94
|
+
if (input.existingIntent) action = input.existingIntent.amountCents === amountCents ? "reuse" : "update";
|
|
95
|
+
return {
|
|
96
|
+
action, amountCents, currency: input.currency, idempotencyKey: key,
|
|
97
|
+
...(input.existingIntent ? { intentId: input.existingIntent.id } : {}),
|
|
98
|
+
...(amountVerdict ? { amountVerdict } : {}),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Map a Stripe PaymentMethod to a CardInfo (pure). `defaultId` marks the default card. */
|
|
103
|
+
export function cardInfoFrom(pm: PaymentMethodLike, defaultId?: string | null): CardInfo {
|
|
104
|
+
return {
|
|
105
|
+
id: pm.id,
|
|
106
|
+
brand: pm.card?.brand,
|
|
107
|
+
last4: pm.card?.last4,
|
|
108
|
+
expMonth: pm.card?.exp_month,
|
|
109
|
+
expYear: pm.card?.exp_year,
|
|
110
|
+
isDefault: pm.id === defaultId,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Does a payment method belong to this customer? The ownership guard for detach / set-default (no cross-account ops). */
|
|
115
|
+
export function ownsPaymentMethod(pm: PaymentMethodLike, customerId: string): boolean {
|
|
116
|
+
return pm.customer === customerId;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── the Stripe-backed binding ─────────────────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
export interface CheckoutProvider {
|
|
122
|
+
name: string;
|
|
123
|
+
/** Find a customer by email, else create one (saastarter getOrCreateCustomer, services/stripe.ts:143). */
|
|
124
|
+
getOrCreateCustomer(input: { email: string; name?: string; metadata?: Record<string, string> }): Promise<{ customerId: string }>;
|
|
125
|
+
/** The saved-card vault for a customer (default card flagged). */
|
|
126
|
+
listPaymentMethods(customerId: string, defaultPaymentMethodId?: string | null): Promise<CardInfo[]>;
|
|
127
|
+
/** A SetupIntent client secret — to vault a new card without charging. */
|
|
128
|
+
createSetupIntent(customerId: string): Promise<{ clientSecret: string | null }>;
|
|
129
|
+
/** Detach a saved card — guarded: throws if the card isn't this customer's. */
|
|
130
|
+
detachPaymentMethod(customerId: string, paymentMethodId: string): Promise<void>;
|
|
131
|
+
/** Execute an {@link IntentPlan} against Stripe: create (idempotent) / update / reuse. */
|
|
132
|
+
applyIntentPlan(plan: IntentPlan): Promise<PaymentIntentLike>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The Stripe checkout binding (the swap point; another processor implements the same interface). */
|
|
136
|
+
export function stripeCheckout(client: StripeCheckoutLike): CheckoutProvider {
|
|
137
|
+
return {
|
|
138
|
+
name: "stripe",
|
|
139
|
+
async getOrCreateCustomer({ email, name, metadata }) {
|
|
140
|
+
const existing = await client.customers.list({ email, limit: 1 });
|
|
141
|
+
if (existing.data[0]) return { customerId: existing.data[0].id };
|
|
142
|
+
const created = await client.customers.create({ email, ...(name ? { name } : {}), ...(metadata ? { metadata } : {}) });
|
|
143
|
+
return { customerId: created.id };
|
|
144
|
+
},
|
|
145
|
+
async listPaymentMethods(customerId, defaultPaymentMethodId) {
|
|
146
|
+
const { data } = await client.paymentMethods.list({ customer: customerId, type: "card" });
|
|
147
|
+
return data.map((pm) => cardInfoFrom(pm, defaultPaymentMethodId));
|
|
148
|
+
},
|
|
149
|
+
async createSetupIntent(customerId) {
|
|
150
|
+
const si = await client.setupIntents.create({ customer: customerId });
|
|
151
|
+
return { clientSecret: si.client_secret };
|
|
152
|
+
},
|
|
153
|
+
async detachPaymentMethod(customerId, paymentMethodId) {
|
|
154
|
+
const pm = await client.paymentMethods.retrieve(paymentMethodId);
|
|
155
|
+
if (!ownsPaymentMethod(pm, customerId)) throw new Error(`forbidden: payment method ${paymentMethodId} does not belong to ${customerId}`);
|
|
156
|
+
await client.paymentMethods.detach(paymentMethodId);
|
|
157
|
+
},
|
|
158
|
+
async applyIntentPlan(plan) {
|
|
159
|
+
if (plan.action === "reuse" && plan.intentId) return client.paymentIntents.retrieve(plan.intentId);
|
|
160
|
+
if (plan.action === "update" && plan.intentId) return client.paymentIntents.update(plan.intentId, { amount: plan.amountCents, currency: plan.currency });
|
|
161
|
+
return client.paymentIntents.create(
|
|
162
|
+
{ amount: plan.amountCents, currency: plan.currency },
|
|
163
|
+
{ idempotencyKey: plan.idempotencyKey },
|
|
164
|
+
);
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,18 @@
|
|
|
7
7
|
export type { PaymentProvider, StripeLike, Customer, Subscription, WebhookEvent } from "./types";
|
|
8
8
|
export {
|
|
9
9
|
customerParams, productParams, meterParams, meteredPriceParams, subscriptionParams, meterEventParams,
|
|
10
|
-
setupUsageBilling, stripeProvider, usageEventsFromCost, reportCostUsage,
|
|
10
|
+
billingPortalSessionParams, setupUsageBilling, stripeProvider, usageEventsFromCost, reportCostUsage,
|
|
11
11
|
type UsageBillingConfig, type CostBillingConfig,
|
|
12
12
|
} from "./stripe";
|
|
13
|
+
export {
|
|
14
|
+
subtotal, computeDiscountAmount, validateDiscount, prorateDiscount, orderTotal, verifyAmount,
|
|
15
|
+
cartFingerprint, idempotencyKey,
|
|
16
|
+
type CartLine, type Discount, type DiscountResult, type DiscountRejection, type OrderTotal, type AmountVerdict,
|
|
17
|
+
} from "./pricing";
|
|
18
|
+
// the checkout money-path (Phase 1): the pure anti-double-charge / anti-tampering core + the Stripe binding.
|
|
19
|
+
export {
|
|
20
|
+
planPaymentIntent, cardInfoFrom, ownsPaymentMethod, stripeCheckout,
|
|
21
|
+
type IntentPlan, type CardInfo, type CheckoutProvider,
|
|
22
|
+
type StripeCheckoutLike, type PaymentMethodLike, type PaymentIntentLike,
|
|
23
|
+
} from "./checkout";
|
|
24
|
+
export { webhookRouter, STRIPE_EVENTS, type WebhookRouter, type WebhookHandler, type HandleResult } from "./webhook";
|
package/src/pricing.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
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
|
+
/**
|
|
116
|
+
* ANTI-TAMPERING: recompute the total from authoritative line prices + the discount and compare it to the amount
|
|
117
|
+
* the client claims (e.g. a PaymentIntent amount the browser posted). Reject any mismatch beyond `toleranceCents`
|
|
118
|
+
* (default 0 — money is exact). The server must call this before honoring any client-supplied amount.
|
|
119
|
+
*/
|
|
120
|
+
export function verifyAmount(lines: CartLine[], discount: Discount | null | undefined, claimedCents: number, opts: { toleranceCents?: number } = {}): AmountVerdict {
|
|
121
|
+
const expectedCents = orderTotal(lines, discount).totalCents;
|
|
122
|
+
const deltaCents = Math.round(claimedCents) - expectedCents;
|
|
123
|
+
const ok = Math.abs(deltaCents) <= (opts.toleranceCents ?? 0);
|
|
124
|
+
return { ok, expectedCents, claimedCents: Math.round(claimedCents), deltaCents, ...(ok ? {} : { reason: "amount-mismatch" as const }) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A stable fingerprint of the priced cart (+ discount) — order-independent over lines. Two carts that should be
|
|
129
|
+
* charged identically produce the same fingerprint; any price/qty/discount change produces a different one.
|
|
130
|
+
*/
|
|
131
|
+
export function cartFingerprint(lines: CartLine[], discount?: Discount | null): string {
|
|
132
|
+
const norm = lines
|
|
133
|
+
.map((l) => `${l.id ?? "?"}:${Math.round(l.unitCents)}x${Math.trunc(l.qty)}`)
|
|
134
|
+
.sort()
|
|
135
|
+
.join("|");
|
|
136
|
+
const d = discount ? `${discount.type}:${discount.value}:${discount.minSubtotalCents ?? ""}` : "";
|
|
137
|
+
const s = `${norm}#${d}`;
|
|
138
|
+
let h = 2166136261;
|
|
139
|
+
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; }
|
|
140
|
+
return h.toString(16).padStart(8, "0");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A deterministic idempotency key for a checkout attempt. The SAME cart under the same scope (principal) yields
|
|
145
|
+
* the SAME key, so a retried "create payment intent" REUSES the existing intent instead of charging twice; a
|
|
146
|
+
* changed cart yields a new key. Thread this into the processor's idempotency-key header.
|
|
147
|
+
*/
|
|
148
|
+
export function idempotencyKey(scope: string, lines: CartLine[], discount?: Discount | null): string {
|
|
149
|
+
return `co_${scope}_${cartFingerprint(lines, discount)}`;
|
|
150
|
+
}
|
package/src/stripe.ts
CHANGED
|
@@ -41,6 +41,13 @@ export function subscriptionParams(i: { customerId: string; priceId: string }):
|
|
|
41
41
|
return { customer: i.customerId, items: [{ price: i.priceId }] };
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** A Billing customer-portal session — the hosted page where a customer manages saved cards + invoices. */
|
|
45
|
+
export function billingPortalSessionParams(i: { customerId: string; returnUrl: string; configuration?: string }): Record<string, unknown> {
|
|
46
|
+
const p: Record<string, unknown> = { customer: i.customerId, return_url: i.returnUrl };
|
|
47
|
+
if (i.configuration) p.configuration = i.configuration; // an explicit portal configuration id (else Stripe's default)
|
|
48
|
+
return p;
|
|
49
|
+
}
|
|
50
|
+
|
|
44
51
|
/** A meter event — reports usage for a customer (the `value` is what you price on). */
|
|
45
52
|
export function meterEventParams(i: { eventName: string; customerId: string; value: number; at?: number }): Record<string, unknown> {
|
|
46
53
|
const payload: Record<string, unknown> = { stripe_customer_id: i.customerId, value: String(i.value) };
|
|
@@ -75,6 +82,10 @@ export function stripeProvider(client: StripeLike, cfg: { webhookSecret?: string
|
|
|
75
82
|
async createCustomer(i) { return { id: (await client.customers.create(customerParams(i))).id }; },
|
|
76
83
|
async subscribeMetered(i) { return { id: (await client.subscriptions.create(subscriptionParams(i))).id }; },
|
|
77
84
|
async reportUsage(i) { await client.billing.meterEvents.create(meterEventParams(i)); },
|
|
85
|
+
async billingPortalUrl(i) {
|
|
86
|
+
if (!client.billingPortal) throw new Error("This Stripe client has no billingPortal support (the REST adapter / SDK provides it).");
|
|
87
|
+
return { url: (await client.billingPortal.sessions.create(billingPortalSessionParams(i))).url };
|
|
88
|
+
},
|
|
78
89
|
verifyWebhook(body, signature) {
|
|
79
90
|
const e = client.webhooks.constructEvent(body, signature, cfg.webhookSecret ?? "");
|
|
80
91
|
return { type: e.type, data: e.data };
|
package/src/types.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface PaymentProvider {
|
|
|
20
20
|
subscribeMetered(input: { customerId: string; priceId: string }): Promise<Subscription>;
|
|
21
21
|
/** Report usage for billing — one meter event (the value you price on). `at` is an input (reproducible). */
|
|
22
22
|
reportUsage(input: { customerId: string; eventName: string; value: number; at?: number }): Promise<void>;
|
|
23
|
+
/** Open the hosted customer billing portal (manage saved cards + invoices). Optional — not every processor has one. */
|
|
24
|
+
billingPortalUrl?(input: { customerId: string; returnUrl: string }): Promise<{ url: string }>;
|
|
23
25
|
/** Verify + parse a webhook from the raw body + signature. */
|
|
24
26
|
verifyWebhook(rawBody: string, signature: string): WebhookEvent;
|
|
25
27
|
}
|
|
@@ -34,5 +36,7 @@ export interface StripeLike {
|
|
|
34
36
|
meters: { create(params: Record<string, unknown>): Promise<{ id: string }> };
|
|
35
37
|
meterEvents: { create(params: Record<string, unknown>): Promise<{ identifier?: string }> };
|
|
36
38
|
};
|
|
39
|
+
/** The Billing customer portal. Optional — present on the real SDK + the REST adapter, omitted by minimal mocks. */
|
|
40
|
+
billingPortal?: { sessions: { create(params: Record<string, unknown>): Promise<{ url: string }> } };
|
|
37
41
|
webhooks: { constructEvent(body: string, sig: string, secret: string): { type: string; data: unknown } };
|
|
38
42
|
}
|
package/src/webhook.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A typed webhook event-router (saastarter-parity Phase 1) layered over the existing `verifyWebhook` (stripe.ts).
|
|
3
|
+
* `verifyWebhook` proves a payload is authentic; this dispatches it to a per-event-type handler — the structured
|
|
4
|
+
* alternative to one giant `switch (event.type)`. Pure routing: no Stripe calls, no network.
|
|
5
|
+
*/
|
|
6
|
+
import type { WebhookEvent } from "./types";
|
|
7
|
+
|
|
8
|
+
export type WebhookHandler = (event: WebhookEvent) => void | Promise<void>;
|
|
9
|
+
|
|
10
|
+
export interface HandleResult {
|
|
11
|
+
type: string;
|
|
12
|
+
/** a registered handler ran (false ⇒ the unhandled fallback ran, or nothing matched). */
|
|
13
|
+
handled: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface WebhookRouter {
|
|
17
|
+
/** register (or replace) the handler for an event type; chainable. */
|
|
18
|
+
on(type: string, handler: WebhookHandler): WebhookRouter;
|
|
19
|
+
/** register a fallback for types with no specific handler; chainable. */
|
|
20
|
+
onUnhandled(handler: WebhookHandler): WebhookRouter;
|
|
21
|
+
/** dispatch one verified event to its handler. */
|
|
22
|
+
handle(event: WebhookEvent): Promise<HandleResult>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Build a router, optionally seeded with a `{ type → handler }` map. */
|
|
26
|
+
export function webhookRouter(handlers: Record<string, WebhookHandler> = {}): WebhookRouter {
|
|
27
|
+
const map = new Map<string, WebhookHandler>(Object.entries(handlers));
|
|
28
|
+
let fallback: WebhookHandler | undefined;
|
|
29
|
+
const router: WebhookRouter = {
|
|
30
|
+
on(type, handler) { map.set(type, handler); return router; },
|
|
31
|
+
onUnhandled(handler) { fallback = handler; return router; },
|
|
32
|
+
async handle(event) {
|
|
33
|
+
const handler = map.get(event.type);
|
|
34
|
+
if (handler) { await handler(event); return { type: event.type, handled: true }; }
|
|
35
|
+
if (fallback) await fallback(event);
|
|
36
|
+
return { type: event.type, handled: false };
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
return router;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The common Stripe checkout/billing event types (for discoverability + typo-safe registration). */
|
|
43
|
+
export const STRIPE_EVENTS = {
|
|
44
|
+
paymentSucceeded: "payment_intent.succeeded",
|
|
45
|
+
paymentFailed: "payment_intent.payment_failed",
|
|
46
|
+
chargeRefunded: "charge.refunded",
|
|
47
|
+
setupSucceeded: "setup_intent.succeeded",
|
|
48
|
+
subscriptionUpdated: "customer.subscription.updated",
|
|
49
|
+
subscriptionDeleted: "customer.subscription.deleted",
|
|
50
|
+
invoicePaid: "invoice.paid",
|
|
51
|
+
} as const;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
planPaymentIntent, cardInfoFrom, ownsPaymentMethod, stripeCheckout,
|
|
4
|
+
webhookRouter, STRIPE_EVENTS,
|
|
5
|
+
type StripeCheckoutLike, type PaymentMethodLike, type CartLine,
|
|
6
|
+
} from "../src/index";
|
|
7
|
+
|
|
8
|
+
const lines: CartLine[] = [{ unitCents: 1999, qty: 2, id: "a" }, { unitCents: 500, qty: 1, id: "b" }]; // 4498
|
|
9
|
+
|
|
10
|
+
describe("planPaymentIntent — the anti-double-charge / anti-tampering core (pure)", () => {
|
|
11
|
+
test("create when there is no existing intent; amount is authoritative; key is deterministic", () => {
|
|
12
|
+
const p = planPaymentIntent({ lines, scope: "u1", currency: "usd" });
|
|
13
|
+
expect(p.action).toBe("create");
|
|
14
|
+
expect(p.amountCents).toBe(4498);
|
|
15
|
+
expect(p.idempotencyKey).toBe(planPaymentIntent({ lines, scope: "u1", currency: "usd" }).idempotencyKey); // deterministic
|
|
16
|
+
expect(p.intentId).toBeUndefined();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("reuse when an existing intent already matches the recomputed total (idempotent retry)", () => {
|
|
20
|
+
const p = planPaymentIntent({ lines, scope: "u1", currency: "usd", existingIntent: { id: "pi_1", amountCents: 4498 } });
|
|
21
|
+
expect(p.action).toBe("reuse");
|
|
22
|
+
expect(p.intentId).toBe("pi_1");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("update when the cart changed (existing intent amount differs)", () => {
|
|
26
|
+
const p = planPaymentIntent({ lines, scope: "u1", currency: "usd", existingIntent: { id: "pi_1", amountCents: 9999 } });
|
|
27
|
+
expect(p.action).toBe("update");
|
|
28
|
+
expect(p.amountCents).toBe(4498); // re-priced to the authoritative total
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("NEVER trusts a client amount: the plan uses the recomputed total + flags a tampered claim", () => {
|
|
32
|
+
const tampered = planPaymentIntent({ lines, scope: "u1", currency: "usd", claimedCents: 1 });
|
|
33
|
+
expect(tampered.amountCents).toBe(4498); // ignores the claimed 1¢
|
|
34
|
+
expect(tampered.amountVerdict?.ok).toBe(false);
|
|
35
|
+
expect(planPaymentIntent({ lines, scope: "u1", currency: "usd", claimedCents: 4498 }).amountVerdict?.ok).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("the idempotency key namespaces by scope (two users' identical carts don't collide)", () => {
|
|
39
|
+
expect(planPaymentIntent({ lines, scope: "u1", currency: "usd" }).idempotencyKey)
|
|
40
|
+
.not.toBe(planPaymentIntent({ lines, scope: "u2", currency: "usd" }).idempotencyKey);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("card vault helpers (pure)", () => {
|
|
45
|
+
const pm: PaymentMethodLike = { id: "pm_1", customer: "cus_1", card: { brand: "visa", last4: "4242", exp_month: 12, exp_year: 2030 } };
|
|
46
|
+
test("cardInfoFrom maps a PaymentMethod and flags the default", () => {
|
|
47
|
+
expect(cardInfoFrom(pm, "pm_1")).toEqual({ id: "pm_1", brand: "visa", last4: "4242", expMonth: 12, expYear: 2030, isDefault: true });
|
|
48
|
+
expect(cardInfoFrom(pm, "pm_other").isDefault).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
test("ownsPaymentMethod guards cross-account ops", () => {
|
|
51
|
+
expect(ownsPaymentMethod(pm, "cus_1")).toBe(true);
|
|
52
|
+
expect(ownsPaymentMethod(pm, "cus_2")).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("stripeCheckout — the Stripe-backed binding", () => {
|
|
57
|
+
function mockClient(overrides: Partial<StripeCheckoutLike> = {}) {
|
|
58
|
+
const calls: { create: unknown[]; piCreate: [unknown, unknown][]; detached: string[] } = { create: [], piCreate: [], detached: [] };
|
|
59
|
+
const client: StripeCheckoutLike = {
|
|
60
|
+
customers: {
|
|
61
|
+
async create(p) { calls.create.push(p); return { id: "cus_new" }; },
|
|
62
|
+
async list() { return { data: [] }; },
|
|
63
|
+
},
|
|
64
|
+
paymentIntents: {
|
|
65
|
+
async create(p, opts) { calls.piCreate.push([p, opts]); return { id: "pi_new", amount: (p as { amount: number }).amount, currency: "usd" }; },
|
|
66
|
+
async retrieve(id) { return { id, amount: 4498, currency: "usd" }; },
|
|
67
|
+
async update(id, p) { return { id, amount: (p as { amount: number }).amount, currency: "usd" }; },
|
|
68
|
+
},
|
|
69
|
+
setupIntents: { async create() { return { client_secret: "seti_secret" }; } },
|
|
70
|
+
paymentMethods: {
|
|
71
|
+
async list() { return { data: [{ id: "pm_1", customer: "cus_1", card: { brand: "visa", last4: "4242" } }] }; },
|
|
72
|
+
async retrieve(id) { return { id, customer: "cus_1" }; },
|
|
73
|
+
async detach(id) { calls.detached.push(id); return { id }; },
|
|
74
|
+
},
|
|
75
|
+
...overrides,
|
|
76
|
+
};
|
|
77
|
+
return { client, calls };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
test("getOrCreateCustomer reuses an existing customer by email, else creates", async () => {
|
|
81
|
+
const found = stripeCheckout({ ...mockClient().client, customers: { async create() { return { id: "x" }; }, async list() { return { data: [{ id: "cus_found" }] }; } } });
|
|
82
|
+
expect(await found.getOrCreateCustomer({ email: "a@b.co" })).toEqual({ customerId: "cus_found" });
|
|
83
|
+
const { client } = mockClient();
|
|
84
|
+
expect(await stripeCheckout(client).getOrCreateCustomer({ email: "new@b.co" })).toEqual({ customerId: "cus_new" });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("listPaymentMethods maps cards + flags the default; createSetupIntent returns a secret", async () => {
|
|
88
|
+
const co = stripeCheckout(mockClient().client);
|
|
89
|
+
expect(await co.listPaymentMethods("cus_1", "pm_1")).toEqual([{ id: "pm_1", brand: "visa", last4: "4242", expMonth: undefined, expYear: undefined, isDefault: true }]);
|
|
90
|
+
expect(await co.createSetupIntent("cus_1")).toEqual({ clientSecret: "seti_secret" });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("detachPaymentMethod detaches an owned card and refuses a foreign one", async () => {
|
|
94
|
+
const { client, calls } = mockClient();
|
|
95
|
+
const co = stripeCheckout(client);
|
|
96
|
+
await co.detachPaymentMethod("cus_1", "pm_1");
|
|
97
|
+
expect(calls.detached).toEqual(["pm_1"]);
|
|
98
|
+
await expect(co.detachPaymentMethod("cus_2", "pm_1")).rejects.toThrow("forbidden");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("applyIntentPlan: create threads the idempotency key; reuse retrieves; update re-prices", async () => {
|
|
102
|
+
const { client, calls } = mockClient();
|
|
103
|
+
const co = stripeCheckout(client);
|
|
104
|
+
await co.applyIntentPlan(planPaymentIntent({ lines, scope: "u1", currency: "usd" }));
|
|
105
|
+
expect(calls.piCreate[0][1]).toMatchObject({ idempotencyKey: expect.stringContaining("co_u1_") });
|
|
106
|
+
expect((await co.applyIntentPlan(planPaymentIntent({ lines, scope: "u1", currency: "usd", existingIntent: { id: "pi_1", amountCents: 4498 } }))).id).toBe("pi_1");
|
|
107
|
+
expect((await co.applyIntentPlan(planPaymentIntent({ lines, scope: "u1", currency: "usd", existingIntent: { id: "pi_1", amountCents: 1 } }))).amount).toBe(4498);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("webhookRouter — typed event dispatch over verifyWebhook", () => {
|
|
112
|
+
test("dispatches to the registered handler; falls back for unknown types", async () => {
|
|
113
|
+
const seen: string[] = [];
|
|
114
|
+
const router = webhookRouter()
|
|
115
|
+
.on(STRIPE_EVENTS.paymentSucceeded, (e) => { seen.push("paid:" + (e.data as { id?: string }).id); })
|
|
116
|
+
.onUnhandled((e) => { seen.push("unhandled:" + e.type); });
|
|
117
|
+
expect(await router.handle({ type: STRIPE_EVENTS.paymentSucceeded, data: { id: "pi_9" } })).toEqual({ type: "payment_intent.succeeded", handled: true });
|
|
118
|
+
expect(await router.handle({ type: "charge.refunded", data: {} })).toEqual({ type: "charge.refunded", handled: false });
|
|
119
|
+
expect(seen).toEqual(["paid:pi_9", "unhandled:charge.refunded"]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("seeded handler map works too", async () => {
|
|
123
|
+
let hit = false;
|
|
124
|
+
const r = webhookRouter({ "invoice.paid": () => { hit = true; } });
|
|
125
|
+
await r.handle({ type: "invoice.paid", data: {} });
|
|
126
|
+
expect(hit).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
subtotal, computeDiscountAmount, validateDiscount, prorateDiscount, orderTotal, verifyAmount,
|
|
4
|
+
cartFingerprint, idempotencyKey, requiresStripe, STRIPE_MIN_CHARGE_CENTS, type CartLine, type Discount,
|
|
5
|
+
} from "../src/index";
|
|
6
|
+
|
|
7
|
+
const lines = (xs: [number, number][]): CartLine[] => xs.map(([unitCents, qty], i) => ({ unitCents, qty, id: i + 1 }));
|
|
8
|
+
|
|
9
|
+
describe("@suluk/stripe money — pure pricing primitives (the trust layer)", () => {
|
|
10
|
+
test("subtotal is integer cents, qty/price clamped non-negative + truncated", () => {
|
|
11
|
+
expect(subtotal(lines([[1000, 2], [250, 3]]))).toBe(2750);
|
|
12
|
+
expect(subtotal([{ unitCents: 100.6, qty: 2.9 }])).toBe(101 * 2); // round price, trunc qty → 101*2
|
|
13
|
+
expect(subtotal([{ unitCents: -5, qty: 3 }, { unitCents: 100, qty: -1 }])).toBe(0); // no negatives
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("computeDiscountAmount — rounded + CLAMPED to the subtotal (no over-discount, no negatives)", () => {
|
|
17
|
+
test("percent rounds to a whole cent", () => {
|
|
18
|
+
expect(computeDiscountAmount(999, { type: "percent", value: 10 })).toBe(100); // 99.9 → 100
|
|
19
|
+
expect(computeDiscountAmount(1000, { type: "percent", value: 15 })).toBe(150);
|
|
20
|
+
});
|
|
21
|
+
test("a discount can never exceed the subtotal", () => {
|
|
22
|
+
expect(computeDiscountAmount(500, { type: "fixed", value: 9999 })).toBe(500); // clamped
|
|
23
|
+
expect(computeDiscountAmount(500, { type: "percent", value: 200 })).toBe(500); // clamped (even if pct absurd)
|
|
24
|
+
});
|
|
25
|
+
test("zero subtotal → zero discount", () => {
|
|
26
|
+
expect(computeDiscountAmount(0, { type: "percent", value: 50 })).toBe(0);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("validateDiscount — specific rejection reasons (shopper is told WHY)", () => {
|
|
31
|
+
const sub = 5000;
|
|
32
|
+
test("no discount", () => expect(validateDiscount(sub, null)).toMatchObject({ valid: false, reason: "no-discount", amountCents: 0 }));
|
|
33
|
+
test("non-positive value", () => expect(validateDiscount(sub, { type: "fixed", value: 0 })).toMatchObject({ valid: false, reason: "non-positive-value" }));
|
|
34
|
+
test("percent over 100", () => expect(validateDiscount(sub, { type: "percent", value: 150 })).toMatchObject({ valid: false, reason: "percent-out-of-range" }));
|
|
35
|
+
test("below minimum", () => expect(validateDiscount(1000, { type: "percent", value: 10, minSubtotalCents: 5000 })).toMatchObject({ valid: false, reason: "below-minimum" }));
|
|
36
|
+
test("valid → the computed amount", () => expect(validateDiscount(sub, { type: "percent", value: 10, minSubtotalCents: 5000 })).toMatchObject({ valid: true, amountCents: 500 }));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("prorateDiscount — sums EXACTLY to the order discount (cart drawer can't drift from order summary)", () => {
|
|
40
|
+
test("a discount that doesn't divide evenly still sums exactly, by largest-remainder", () => {
|
|
41
|
+
const ls = lines([[100, 1], [100, 1], [100, 1]]); // 3 lines × $1.00
|
|
42
|
+
const shares = prorateDiscount(ls, 10); // 10¢ over 3 lines = 3,3,4 (or perm), sum = 10
|
|
43
|
+
expect(shares.reduce((a, b) => a + b, 0)).toBe(10);
|
|
44
|
+
expect(shares.every((s) => s >= 0)).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
test("proportional to line totals, exact sum", () => {
|
|
47
|
+
const ls = lines([[1000, 1], [3000, 1]]); // $10 and $30 → 1:3
|
|
48
|
+
const shares = prorateDiscount(ls, 401); // 401¢ → ~100.25 / 300.75 → sums to 401
|
|
49
|
+
expect(shares.reduce((a, b) => a + b, 0)).toBe(401);
|
|
50
|
+
expect(shares[1]).toBeGreaterThan(shares[0]); // the bigger line absorbs more
|
|
51
|
+
});
|
|
52
|
+
test("no line share exceeds its own total", () => {
|
|
53
|
+
const ls = lines([[100, 1], [10000, 1]]);
|
|
54
|
+
const shares = prorateDiscount(ls, 150); // small line is only 100¢
|
|
55
|
+
expect(shares[0]).toBeLessThanOrEqual(100);
|
|
56
|
+
expect(shares.reduce((a, b) => a + b, 0)).toBe(150);
|
|
57
|
+
});
|
|
58
|
+
test("zero/empty cases", () => {
|
|
59
|
+
expect(prorateDiscount(lines([[100, 1]]), 0)).toEqual([0]);
|
|
60
|
+
expect(prorateDiscount([], 50)).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
test("randomized invariant: proration always sums to min(want, gross) and is non-negative", () => {
|
|
63
|
+
for (let seed = 1; seed <= 200; seed++) {
|
|
64
|
+
const n = (seed % 5) + 1;
|
|
65
|
+
const ls = Array.from({ length: n }, (_, i) => ({ unitCents: ((seed * (i + 7)) % 900) + 1, qty: ((seed + i) % 3) + 1, id: i }));
|
|
66
|
+
const gross = subtotal(ls);
|
|
67
|
+
const want = (seed * 13) % (gross + 50); // sometimes exceeds gross → should clamp
|
|
68
|
+
const shares = prorateDiscount(ls, want);
|
|
69
|
+
expect(shares.reduce((a, b) => a + b, 0)).toBe(Math.min(want, gross));
|
|
70
|
+
expect(shares.every((s, i) => s >= 0 && s <= ls[i].unitCents * ls[i].qty)).toBe(true);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("orderTotal composes subtotal − validated discount", () => {
|
|
76
|
+
const ls = lines([[1000, 2], [500, 1]]); // 2500
|
|
77
|
+
expect(orderTotal(ls, { type: "percent", value: 10 })).toEqual({ subtotalCents: 2500, discountCents: 250, totalCents: 2250 });
|
|
78
|
+
expect(orderTotal(ls, { type: "percent", value: 10, minSubtotalCents: 9999 })).toEqual({ subtotalCents: 2500, discountCents: 0, totalCents: 2500 }); // ineligible → no discount
|
|
79
|
+
expect(orderTotal(ls)).toEqual({ subtotalCents: 2500, discountCents: 0, totalCents: 2500 });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("verifyAmount — anti-tampering recompute (never trust the client's amount)", () => {
|
|
83
|
+
const ls = lines([[1999, 2]]); // $39.98
|
|
84
|
+
test("a matching amount passes", () => {
|
|
85
|
+
expect(verifyAmount(ls, null, 3998)).toMatchObject({ ok: true, expectedCents: 3998, deltaCents: 0 });
|
|
86
|
+
});
|
|
87
|
+
test("a tampered (lowered) amount is rejected with the delta", () => {
|
|
88
|
+
const v = verifyAmount(ls, null, 100); // attacker claims $1.00
|
|
89
|
+
expect(v.ok).toBe(false);
|
|
90
|
+
expect(v.reason).toBe("amount-mismatch");
|
|
91
|
+
expect(v.expectedCents).toBe(3998);
|
|
92
|
+
expect(v.deltaCents).toBe(100 - 3998);
|
|
93
|
+
});
|
|
94
|
+
test("the discount is part of the authoritative recompute", () => {
|
|
95
|
+
expect(verifyAmount(ls, { type: "percent", value: 50 }, 1999)).toMatchObject({ ok: true }); // 50% of 3998 = 1999
|
|
96
|
+
expect(verifyAmount(ls, { type: "percent", value: 50 }, 3998).ok).toBe(false); // client ignored the discount? still must match server
|
|
97
|
+
});
|
|
98
|
+
test("tolerance is honored", () => {
|
|
99
|
+
expect(verifyAmount(ls, null, 3999, { toleranceCents: 1 }).ok).toBe(true);
|
|
100
|
+
expect(verifyAmount(ls, null, 4000, { toleranceCents: 1 }).ok).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("idempotency — a stable key for intent-reuse (no double-charge)", () => {
|
|
105
|
+
const a = lines([[1000, 1], [500, 2]]);
|
|
106
|
+
test("the same cart → the same key (a retry reuses one intent)", () => {
|
|
107
|
+
expect(idempotencyKey("user-1", a)).toBe(idempotencyKey("user-1", a));
|
|
108
|
+
});
|
|
109
|
+
test("line order doesn't change the fingerprint", () => {
|
|
110
|
+
expect(cartFingerprint(a)).toBe(cartFingerprint([...a].reverse()));
|
|
111
|
+
});
|
|
112
|
+
test("a changed cart → a different key", () => {
|
|
113
|
+
expect(idempotencyKey("user-1", a)).not.toBe(idempotencyKey("user-1", lines([[1000, 1], [500, 3]])));
|
|
114
|
+
expect(idempotencyKey("user-1", a)).not.toBe(idempotencyKey("user-1", a, { type: "percent", value: 10 }));
|
|
115
|
+
});
|
|
116
|
+
test("a different principal → a different key (no cross-tenant intent reuse)", () => {
|
|
117
|
+
expect(idempotencyKey("user-1", a)).not.toBe(idempotencyKey("user-2", a));
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("maxDiscountCents — capping a percentage discount", () => {
|
|
122
|
+
test("a percent discount is capped at maxDiscountCents", () => {
|
|
123
|
+
expect(computeDiscountAmount(30000, { type: "percent", value: 30, maxDiscountCents: 5000 })).toBe(5000); // 9000 → capped 5000
|
|
124
|
+
});
|
|
125
|
+
test("under the cap, the full percent applies", () => {
|
|
126
|
+
expect(computeDiscountAmount(10000, { type: "percent", value: 30, maxDiscountCents: 5000 })).toBe(3000);
|
|
127
|
+
});
|
|
128
|
+
test("no cap → uncapped (back-compat)", () => {
|
|
129
|
+
expect(computeDiscountAmount(30000, { type: "percent", value: 30 })).toBe(9000);
|
|
130
|
+
});
|
|
131
|
+
test("the cap still respects the [0, subtotal] clamp", () => {
|
|
132
|
+
expect(computeDiscountAmount(2000, { type: "fixed", value: 9999, maxDiscountCents: 5000 })).toBe(2000);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("requiresStripe — the $0.50 floor / free-order decision", () => {
|
|
137
|
+
test("a chargeable total requires Stripe", () => {
|
|
138
|
+
expect(requiresStripe(2900)).toBe(true);
|
|
139
|
+
expect(requiresStripe(STRIPE_MIN_CHARGE_CENTS)).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
test("$0 and sub-floor totals go the free path", () => {
|
|
142
|
+
expect(requiresStripe(0)).toBe(false);
|
|
143
|
+
expect(requiresStripe(49)).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
test("poison totals never require a charge", () => {
|
|
146
|
+
expect(requiresStripe(NaN)).toBe(false);
|
|
147
|
+
expect(requiresStripe(-100)).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
});
|
package/test/stripe.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { test, expect, describe } from "bun:test";
|
|
|
2
2
|
import type { CostEvent } from "@suluk/cost";
|
|
3
3
|
import {
|
|
4
4
|
customerParams, meterParams, meteredPriceParams, subscriptionParams, meterEventParams,
|
|
5
|
-
setupUsageBilling, stripeProvider, usageEventsFromCost, reportCostUsage, type StripeLike,
|
|
5
|
+
billingPortalSessionParams, setupUsageBilling, stripeProvider, usageEventsFromCost, reportCostUsage, type StripeLike,
|
|
6
6
|
} from "../src/index";
|
|
7
7
|
|
|
8
8
|
/** A recording mock that satisfies StripeLike — records every call + returns fake ids. */
|
|
@@ -18,6 +18,7 @@ function mockStripe() {
|
|
|
18
18
|
meters: { create: rec("billing.meters.create", "mtr_1") },
|
|
19
19
|
meterEvents: { create: rec("billing.meterEvents.create", "evt_1") },
|
|
20
20
|
},
|
|
21
|
+
billingPortal: { sessions: { create: async (params: Record<string, unknown>) => { calls.push({ method: "billingPortal.sessions.create", params }); return { url: "https://billing.stripe.com/p/session_1" }; } } },
|
|
21
22
|
webhooks: { constructEvent: (b, s, secret) => { calls.push({ method: "webhooks.constructEvent", params: { b, s, secret } }); return { type: "invoice.paid", data: { ok: true } }; } },
|
|
22
23
|
};
|
|
23
24
|
return { client, calls };
|
|
@@ -41,6 +42,10 @@ describe("pure param builders → exact Stripe payloads", () => {
|
|
|
41
42
|
expect(customerParams({ email: "a@b.c" })).toEqual({ email: "a@b.c" });
|
|
42
43
|
expect(subscriptionParams({ customerId: "cus_1", priceId: "price_1" })).toEqual({ customer: "cus_1", items: [{ price: "price_1" }] });
|
|
43
44
|
});
|
|
45
|
+
test("billing-portal session params (customer + return_url, optional configuration)", () => {
|
|
46
|
+
expect(billingPortalSessionParams({ customerId: "cus_1", returnUrl: "https://x.dev/account" })).toEqual({ customer: "cus_1", return_url: "https://x.dev/account" });
|
|
47
|
+
expect(billingPortalSessionParams({ customerId: "cus_1", returnUrl: "https://x.dev/account", configuration: "bpc_1" })).toMatchObject({ configuration: "bpc_1" });
|
|
48
|
+
});
|
|
44
49
|
});
|
|
45
50
|
|
|
46
51
|
describe("flows over a StripeLike client", () => {
|
|
@@ -59,6 +64,8 @@ describe("flows over a StripeLike client", () => {
|
|
|
59
64
|
expect((await stripe.createCustomer({ email: "a@b.c" })).id).toBe("cus_1");
|
|
60
65
|
expect((await stripe.subscribeMetered({ customerId: "cus_1", priceId: "price_1" })).id).toBe("sub_1");
|
|
61
66
|
await stripe.reportUsage({ customerId: "cus_1", eventName: "api_cost", value: 4050 });
|
|
67
|
+
expect((await stripe.billingPortalUrl!({ customerId: "cus_1", returnUrl: "https://x.dev/account" })).url).toBe("https://billing.stripe.com/p/session_1");
|
|
68
|
+
expect(calls.find((c) => c.method === "billingPortal.sessions.create")!.params).toEqual({ customer: "cus_1", return_url: "https://x.dev/account" });
|
|
62
69
|
const evt = stripe.verifyWebhook("{raw}", "sig_1");
|
|
63
70
|
expect(evt.type).toBe("invoice.paid");
|
|
64
71
|
expect(calls.find((c) => c.method === "billing.meterEvents.create")!.params).toMatchObject({ event_name: "api_cost", payload: { stripe_customer_id: "cus_1", value: "4050" } });
|