@omg-dev/billing 0.4.24

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.
@@ -0,0 +1,226 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ defineBilling,
4
+ addOn,
5
+ limit,
6
+ overage,
7
+ plan,
8
+ rateCard,
9
+ stripe,
10
+ polar,
11
+ usd,
12
+ money,
13
+ formatMoney,
14
+ MICROS_PER_UNIT,
15
+ } from "../index.ts";
16
+ import {
17
+ toCatalog,
18
+ serializeCatalog,
19
+ stableStringify,
20
+ planHash,
21
+ addOnHash,
22
+ fnv1a,
23
+ } from "../catalog.ts";
24
+
25
+ function fixture() {
26
+ return defineBilling({
27
+ provider: stripe(),
28
+ credit: { unit: "usd", peg: usd(1) },
29
+ features: {
30
+ llm_usage: { kind: "metered", label: "AI usage" },
31
+ published: { kind: "boolean" },
32
+ },
33
+ usage: {
34
+ llm_usage: rateCard({
35
+ "claude-opus-4-7": { in: usd(0.000015), out: usd(0.000075) },
36
+ }),
37
+ },
38
+ plans: {
39
+ free: plan({
40
+ price: usd(0),
41
+ default: true,
42
+ includes: { llm_usage: limit("$5"), published: false },
43
+ }),
44
+ pro: plan({
45
+ price: usd(20),
46
+ interval: "month",
47
+ includes: { llm_usage: limit("$25"), published: true },
48
+ usage: { llm_usage: overage() },
49
+ }),
50
+ },
51
+ addOns: {
52
+ seats: addOn({
53
+ price: usd(5),
54
+ interval: "month",
55
+ unit: "seat",
56
+ sizeMultipliers: { small: 1, large: 4 },
57
+ }),
58
+ },
59
+ grants: {
60
+ signup: { on: "customer.created", amount: usd(5), feature: "llm_usage" },
61
+ },
62
+ });
63
+ }
64
+
65
+ describe("money", () => {
66
+ it("converts dollars to integer micro-units", () => {
67
+ expect(usd(1)).toBe(MICROS_PER_UNIT);
68
+ expect(usd(5)).toBe(5_000_000);
69
+ expect(usd(0.000015)).toBe(15);
70
+ });
71
+
72
+ it("never carries a float at rest", () => {
73
+ expect(Number.isInteger(usd(0.000075))).toBe(true);
74
+ });
75
+
76
+ it("parses money strings", () => {
77
+ expect(money("$5")).toBe(5_000_000);
78
+ expect(money("0.25")).toBe(250_000);
79
+ expect(money(42 as never)).toBe(42);
80
+ });
81
+
82
+ it("rejects unparseable money", () => {
83
+ expect(() => money("abc")).toThrow();
84
+ });
85
+
86
+ it("formats back to a dollar string", () => {
87
+ expect(formatMoney(usd(5))).toBe("$5");
88
+ expect(formatMoney(usd(0.25))).toBe("$0.25");
89
+ });
90
+ });
91
+
92
+ describe("defineBilling validation", () => {
93
+ it("builds a normalized billing object", () => {
94
+ const b = fixture();
95
+ expect(b.plans.length).toBe(2);
96
+ expect(b.addOns.length).toBe(1);
97
+ expect(b.features.length).toBe(2);
98
+ expect(b.grants.length).toBe(1);
99
+ });
100
+
101
+ it("auto-labels features from their key", () => {
102
+ const b = fixture();
103
+ expect(b.features.find((f) => f.key === "published")?.label).toBe("Published");
104
+ });
105
+
106
+ it("requires exactly one default plan", () => {
107
+ expect(() =>
108
+ defineBilling({
109
+ provider: stripe(),
110
+ credit: { unit: "usd", peg: usd(1) },
111
+ features: { x: { kind: "boolean" } },
112
+ plans: { a: plan({ price: usd(0), includes: { x: true } }) },
113
+ }),
114
+ ).toThrow(/exactly one plan must be default/);
115
+ });
116
+
117
+ it("rejects a metered inclusion that is a boolean", () => {
118
+ expect(() =>
119
+ defineBilling({
120
+ provider: stripe(),
121
+ credit: { unit: "usd", peg: usd(1) },
122
+ features: { m: { kind: "metered" } },
123
+ plans: { a: plan({ price: usd(0), default: true, includes: { m: true } }) },
124
+ }),
125
+ ).toThrow(/is metered but got a boolean/);
126
+ });
127
+
128
+ it("rejects a rate card on an unknown feature", () => {
129
+ expect(() =>
130
+ defineBilling({
131
+ provider: polar(),
132
+ credit: { unit: "usd", peg: usd(1) },
133
+ features: { m: { kind: "metered" } },
134
+ usage: { ghost: rateCard({ x: { in: usd(1), out: usd(1) } }) },
135
+ plans: { a: plan({ price: usd(0), default: true, includes: { m: limit("$1") } }) },
136
+ }),
137
+ ).toThrow(/unknown feature/);
138
+ });
139
+
140
+ it("rejects a grant on a boolean feature", () => {
141
+ expect(() =>
142
+ defineBilling({
143
+ provider: stripe(),
144
+ credit: { unit: "usd", peg: usd(1) },
145
+ features: { b: { kind: "boolean" } },
146
+ plans: { a: plan({ price: usd(0), default: true, includes: { b: true } }) },
147
+ grants: { g: { on: "customer.created", amount: usd(5), feature: "b" } },
148
+ }),
149
+ ).toThrow(/grant on non-metered/);
150
+ });
151
+ });
152
+
153
+ describe("provider", () => {
154
+ it("never uses provider-side meters", () => {
155
+ expect(stripe().useProviderMeters).toBe(false);
156
+ expect(polar().useProviderMeters).toBe(false);
157
+ });
158
+ });
159
+
160
+ describe("catalog serialization", () => {
161
+ it("emits stable, sorted JSON regardless of key order", () => {
162
+ expect(stableStringify({ b: 1, a: 2 })).toBe('{"a":2,"b":1}');
163
+ expect(stableStringify({ a: 2, b: 1 })).toBe('{"a":2,"b":1}');
164
+ });
165
+
166
+ it("drops undefined fields", () => {
167
+ expect(stableStringify({ a: 1, b: undefined })).toBe('{"a":1}');
168
+ });
169
+
170
+ it("is deterministic across repeated serialization", () => {
171
+ const a = serializeCatalog(fixture());
172
+ const b = serializeCatalog(fixture());
173
+ expect(a).toBe(b);
174
+ });
175
+
176
+ it("includes a plan hash per plan", () => {
177
+ const cat = toCatalog(fixture());
178
+ for (const p of cat.plans) {
179
+ expect(p.hash).toMatch(/^[0-9a-f]{8}$/);
180
+ }
181
+ });
182
+
183
+ it("includes add-on hashes", () => {
184
+ const cat = toCatalog(fixture());
185
+ expect(cat.addOns).toHaveLength(1);
186
+ expect(cat.addOns[0].hash).toMatch(/^[0-9a-f]{8}$/);
187
+ expect(cat.addOns[0].priceHash).toMatch(/^[0-9a-f]{8}$/);
188
+ });
189
+
190
+ it("plan hash excludes cosmetic copy (label/description)", () => {
191
+ const base = fixture();
192
+ const free1 = base.plans.find((p) => p.key === "free")!;
193
+ const relabeled = { ...free1, label: "Totally Different", description: "x" };
194
+ expect(planHash(relabeled)).toBe(planHash(free1));
195
+ });
196
+
197
+ it("plan hash changes when price changes", () => {
198
+ const base = fixture();
199
+ const free = base.plans.find((p) => p.key === "free")!;
200
+ const pricier = { ...free, price: usd(9) as typeof free.price };
201
+ expect(planHash(pricier)).not.toBe(planHash(free));
202
+ });
203
+
204
+ it("plan hash changes when an allowance changes", () => {
205
+ const base = fixture();
206
+ const free = base.plans.find((p) => p.key === "free")!;
207
+ const bumped = {
208
+ ...free,
209
+ inclusions: free.inclusions.map((i) =>
210
+ i.kind === "metered" ? { ...i, allowance: usd(50) as typeof i.allowance } : i,
211
+ ),
212
+ };
213
+ expect(planHash(bumped)).not.toBe(planHash(free));
214
+ });
215
+
216
+ it("fnv1a is stable", () => {
217
+ expect(fnv1a("")).toBe("811c9dc5");
218
+ expect(fnv1a("hello")).toBe(fnv1a("hello"));
219
+ });
220
+
221
+ it("add-on hash changes when provider-relevant price changes", () => {
222
+ const base = fixture().addOns[0];
223
+ const pricier = { ...base, price: usd(9) as typeof base.price };
224
+ expect(addOnHash(pricier)).not.toBe(addOnHash(base));
225
+ });
226
+ });