@suluk/stripe 0.1.3 → 0.1.5
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 +12 -3
- package/src/pricing.ts +17 -0
- package/src/shipping.ts +65 -0
- package/src/tax.ts +52 -0
- package/test/shipping-tax.test.ts +54 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/stripe",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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,
|
|
15
|
-
cartFingerprint, idempotencyKey,
|
|
16
|
-
type CartLine, type Discount, type DiscountResult, type DiscountRejection, type OrderTotal, type AmountVerdict,
|
|
14
|
+
subtotal, computeDiscountAmount, validateDiscount, prorateDiscount, orderTotal, composeTotal, verifyAmount,
|
|
15
|
+
cartFingerprint, idempotencyKey, requiresStripe, STRIPE_MIN_CHARGE_CENTS,
|
|
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,
|
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/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,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
|
+
});
|