@suluk/stripe 0.1.2 → 0.1.4
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 +1 -1
- package/src/pricing.ts +14 -1
- package/test/pricing.test.ts +31 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/stripe",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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
|
@@ -12,7 +12,7 @@ export {
|
|
|
12
12
|
} from "./stripe";
|
|
13
13
|
export {
|
|
14
14
|
subtotal, computeDiscountAmount, validateDiscount, prorateDiscount, orderTotal, verifyAmount,
|
|
15
|
-
cartFingerprint, idempotencyKey,
|
|
15
|
+
cartFingerprint, idempotencyKey, requiresStripe, STRIPE_MIN_CHARGE_CENTS,
|
|
16
16
|
type CartLine, type Discount, type DiscountResult, type DiscountRejection, type OrderTotal, type AmountVerdict,
|
|
17
17
|
} from "./pricing";
|
|
18
18
|
// the checkout money-path (Phase 1): the pure anti-double-charge / anti-tampering core + the Stripe binding.
|
package/src/pricing.ts
CHANGED
|
@@ -26,6 +26,17 @@ export interface Discount {
|
|
|
26
26
|
value: number;
|
|
27
27
|
/** the discount only applies at/above this subtotal (cents). */
|
|
28
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;
|
|
29
40
|
}
|
|
30
41
|
|
|
31
42
|
export interface DiscountResult { valid: boolean; amountCents: number; reason?: DiscountRejection }
|
|
@@ -53,7 +64,9 @@ export function computeDiscountAmount(subtotalCents: number, d: Discount): numbe
|
|
|
53
64
|
const base = Math.max(0, Math.round(fin(subtotalCents)));
|
|
54
65
|
if (base === 0 || !Number.isFinite(d.value) || d.value <= 0) return 0; // a non-finite/non-positive value buys nothing
|
|
55
66
|
const raw = d.type === "percent" ? (base * d.value) / 100 : d.value;
|
|
56
|
-
|
|
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)));
|
|
57
70
|
}
|
|
58
71
|
|
|
59
72
|
/**
|
package/test/pricing.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test, expect, describe } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
subtotal, computeDiscountAmount, validateDiscount, prorateDiscount, orderTotal, verifyAmount,
|
|
4
|
-
cartFingerprint, idempotencyKey, type CartLine, type Discount,
|
|
4
|
+
cartFingerprint, idempotencyKey, requiresStripe, STRIPE_MIN_CHARGE_CENTS, type CartLine, type Discount,
|
|
5
5
|
} from "../src/index";
|
|
6
6
|
|
|
7
7
|
const lines = (xs: [number, number][]): CartLine[] => xs.map(([unitCents, qty], i) => ({ unitCents, qty, id: i + 1 }));
|
|
@@ -117,4 +117,34 @@ describe("@suluk/stripe money — pure pricing primitives (the trust layer)", ()
|
|
|
117
117
|
expect(idempotencyKey("user-1", a)).not.toBe(idempotencyKey("user-2", a));
|
|
118
118
|
});
|
|
119
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
|
+
});
|
|
120
150
|
});
|