@omg-dev/billing 0.4.33 → 0.4.35
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/dist/catalog.mjs +1 -0
- package/dist/index.mjs +3 -1
- package/dist/react.mjs +8 -3
- package/package.json +1 -1
- package/src/catalog.ts +7 -0
- package/src/index.ts +24 -0
- package/src/react.tsx +8 -3
- package/src/test/billing.test.ts +59 -0
package/dist/catalog.mjs
CHANGED
package/dist/index.mjs
CHANGED
|
@@ -73,6 +73,7 @@ function defineBilling(config) {
|
|
|
73
73
|
if (planKeys.length === 0) throw new Error("@omg-dev/billing: at least one plan must be declared");
|
|
74
74
|
const defaults = planKeys.filter((k) => config.plans[k].default);
|
|
75
75
|
if (defaults.length !== 1) throw new Error(`@omg-dev/billing: exactly one plan must be default:true (found ${defaults.length}: [${defaults.join(", ")}])`);
|
|
76
|
+
if (config.plans[defaults[0]].deprecated) throw new Error(`@omg-dev/billing: the default plan "${defaults[0]}" cannot be deprecated — new customers land on it`);
|
|
76
77
|
const rateCards = config.usage ?? {};
|
|
77
78
|
for (const fkey of Object.keys(rateCards)) {
|
|
78
79
|
const f = config.features[fkey];
|
|
@@ -120,7 +121,8 @@ function defineBilling(config) {
|
|
|
120
121
|
price: p.price,
|
|
121
122
|
interval: p.interval,
|
|
122
123
|
default: p.default ?? false,
|
|
123
|
-
inclusions
|
|
124
|
+
inclusions,
|
|
125
|
+
deprecated: p.deprecated ?? false
|
|
124
126
|
};
|
|
125
127
|
});
|
|
126
128
|
const addOns = Object.entries(config.addOns ?? {}).map(([key, a]) => {
|
package/dist/react.mjs
CHANGED
|
@@ -41,15 +41,20 @@ function PricingTable({ billing, currentPlan, onSelectPlan, ctaLabel, renderIncl
|
|
|
41
41
|
},
|
|
42
42
|
"data-vibes-pricing-table": true,
|
|
43
43
|
children: React.useMemo(() => {
|
|
44
|
-
const list =
|
|
44
|
+
const list = billing.plans.filter((p) => !p.deprecated || p.key === currentPlan);
|
|
45
45
|
if (sortByPrice) list.sort((a, b) => a.price - b.price);
|
|
46
46
|
return list;
|
|
47
|
-
}, [
|
|
47
|
+
}, [
|
|
48
|
+
billing.plans,
|
|
49
|
+
currentPlan,
|
|
50
|
+
sortByPrice
|
|
51
|
+
]).map((plan) => {
|
|
48
52
|
const isCurrent = currentPlan === plan.key;
|
|
49
53
|
const lines = inclusionLines(plan, billing, renderInclusion);
|
|
50
54
|
return /* @__PURE__ */ jsxs("div", {
|
|
51
55
|
"data-plan": plan.key,
|
|
52
56
|
"data-default": plan.default || void 0,
|
|
57
|
+
"data-deprecated": plan.deprecated || void 0,
|
|
53
58
|
style: {
|
|
54
59
|
border: "1px solid var(--border, #e5e7eb)",
|
|
55
60
|
borderRadius: "0.75rem",
|
|
@@ -109,7 +114,7 @@ function PricingTable({ billing, currentPlan, onSelectPlan, ctaLabel, renderIncl
|
|
|
109
114
|
padding: "0.5rem"
|
|
110
115
|
},
|
|
111
116
|
"data-current": true,
|
|
112
|
-
children: "Current plan"
|
|
117
|
+
children: plan.deprecated ? "Current plan — no longer offered" : "Current plan"
|
|
113
118
|
}) : onSelectPlan ? /* @__PURE__ */ jsx("button", {
|
|
114
119
|
type: "button",
|
|
115
120
|
onClick: () => onSelectPlan(plan.key),
|
package/package.json
CHANGED
package/src/catalog.ts
CHANGED
|
@@ -29,6 +29,12 @@ export interface CatalogPlan {
|
|
|
29
29
|
window?: "day" | "week" | "month";
|
|
30
30
|
overage?: { allow: boolean };
|
|
31
31
|
}>;
|
|
32
|
+
/** Closed to new subscriptions; grandfathered holders keep it. Omitted when
|
|
33
|
+
* false so an all-current catalog serializes exactly as it did before this
|
|
34
|
+
* field existed. Deliberately outside BOTH hashes: retiring a plan changes
|
|
35
|
+
* nothing the provider bills or the ledger meters, so it must not churn a
|
|
36
|
+
* Stripe product or look like a price change. */
|
|
37
|
+
deprecated?: true;
|
|
32
38
|
/** Stable hash of this plan's FULL billable shape (price + interval +
|
|
33
39
|
* inclusions + windows). Reference / change-detection. */
|
|
34
40
|
hash: string;
|
|
@@ -161,6 +167,7 @@ export function toCatalog(billing: Billing): Catalog {
|
|
|
161
167
|
price: p.price,
|
|
162
168
|
interval: p.interval,
|
|
163
169
|
default: p.default,
|
|
170
|
+
deprecated: p.deprecated ? (true as const) : undefined,
|
|
164
171
|
inclusions: p.inclusions.map((i) => ({
|
|
165
172
|
feature: i.feature,
|
|
166
173
|
kind: i.kind,
|
package/src/index.ts
CHANGED
|
@@ -156,6 +156,18 @@ export interface PlanInput {
|
|
|
156
156
|
includes?: Record<string, Inclusion>;
|
|
157
157
|
/** Per-feature overage policy once the inclusion is exhausted. */
|
|
158
158
|
usage?: Record<string, Overage>;
|
|
159
|
+
/**
|
|
160
|
+
* Closed to NEW subscriptions; existing holders are grandfathered.
|
|
161
|
+
*
|
|
162
|
+
* A retired plan cannot simply be deleted: the key still has to resolve for
|
|
163
|
+
* everyone still on it (allowance grants, entitlement lookups, the provider
|
|
164
|
+
* price their subscription already renews against). So it stays in the
|
|
165
|
+
* catalog and this flag carries the one fact every consumer needs — do not
|
|
166
|
+
* sell it. Pricing surfaces skip it, checkout refuses it, and the plan a
|
|
167
|
+
* grandfathered customer holds can be labelled honestly instead of read as a
|
|
168
|
+
* peer of the tiers on sale.
|
|
169
|
+
*/
|
|
170
|
+
deprecated?: boolean;
|
|
159
171
|
}
|
|
160
172
|
|
|
161
173
|
export interface PlanDef extends PlanInput {
|
|
@@ -255,6 +267,8 @@ export interface NormalizedPlan {
|
|
|
255
267
|
interval?: "month" | "year";
|
|
256
268
|
default: boolean;
|
|
257
269
|
inclusions: NormalizedPlanInclusion[];
|
|
270
|
+
/** Closed to new subscriptions; grandfathered holders keep it. */
|
|
271
|
+
deprecated: boolean;
|
|
258
272
|
}
|
|
259
273
|
|
|
260
274
|
export interface NormalizedAddOn {
|
|
@@ -306,6 +320,15 @@ export function defineBilling(config: BillingConfig): Billing {
|
|
|
306
320
|
);
|
|
307
321
|
}
|
|
308
322
|
|
|
323
|
+
// The default plan is what every new customer lands on, so retiring it would
|
|
324
|
+
// close signup itself — a contradiction worth catching at declaration time
|
|
325
|
+
// rather than at the first checkout.
|
|
326
|
+
if (config.plans[defaults[0]].deprecated) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
`@omg-dev/billing: the default plan "${defaults[0]}" cannot be deprecated — new customers land on it`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
309
332
|
// rate cards may only reference declared metered features
|
|
310
333
|
const rateCards = config.usage ?? {};
|
|
311
334
|
for (const fkey of Object.keys(rateCards)) {
|
|
@@ -377,6 +400,7 @@ export function defineBilling(config: BillingConfig): Billing {
|
|
|
377
400
|
interval: p.interval,
|
|
378
401
|
default: p.default ?? false,
|
|
379
402
|
inclusions,
|
|
403
|
+
deprecated: p.deprecated ?? false,
|
|
380
404
|
};
|
|
381
405
|
});
|
|
382
406
|
|
package/src/react.tsx
CHANGED
|
@@ -80,10 +80,14 @@ export function PricingTable({
|
|
|
80
80
|
sortByPrice = true,
|
|
81
81
|
}: PricingTableProps) {
|
|
82
82
|
const plans = React.useMemo(() => {
|
|
83
|
-
|
|
83
|
+
// A retired plan is not on sale, so it is not a column — with one
|
|
84
|
+
// exception: the person currently ON it. Dropping their card outright
|
|
85
|
+
// would render the table as if they were on the free tier, which is the
|
|
86
|
+
// one reading a pricing table must never produce.
|
|
87
|
+
const list = billing.plans.filter((p) => !p.deprecated || p.key === currentPlan);
|
|
84
88
|
if (sortByPrice) list.sort((a, b) => a.price - b.price);
|
|
85
89
|
return list;
|
|
86
|
-
}, [billing.plans, sortByPrice]);
|
|
90
|
+
}, [billing.plans, currentPlan, sortByPrice]);
|
|
87
91
|
|
|
88
92
|
return (
|
|
89
93
|
<div
|
|
@@ -105,6 +109,7 @@ export function PricingTable({
|
|
|
105
109
|
key={plan.key}
|
|
106
110
|
data-plan={plan.key}
|
|
107
111
|
data-default={plan.default || undefined}
|
|
112
|
+
data-deprecated={plan.deprecated || undefined}
|
|
108
113
|
style={{
|
|
109
114
|
border: "1px solid var(--border, #e5e7eb)",
|
|
110
115
|
borderRadius: "0.75rem",
|
|
@@ -147,7 +152,7 @@ export function PricingTable({
|
|
|
147
152
|
}}
|
|
148
153
|
data-current
|
|
149
154
|
>
|
|
150
|
-
Current plan
|
|
155
|
+
{plan.deprecated ? "Current plan — no longer offered" : "Current plan"}
|
|
151
156
|
</div>
|
|
152
157
|
) : onSelectPlan ? (
|
|
153
158
|
<button
|
package/src/test/billing.test.ts
CHANGED
|
@@ -224,3 +224,62 @@ describe("catalog serialization", () => {
|
|
|
224
224
|
expect(addOnHash(pricier)).not.toBe(addOnHash(base));
|
|
225
225
|
});
|
|
226
226
|
});
|
|
227
|
+
|
|
228
|
+
describe("deprecated plans", () => {
|
|
229
|
+
function withRetired() {
|
|
230
|
+
return defineBilling({
|
|
231
|
+
provider: stripe(),
|
|
232
|
+
credit: { unit: "usd", peg: usd(1) },
|
|
233
|
+
features: { llm_usage: { kind: "metered", label: "AI usage" } },
|
|
234
|
+
plans: {
|
|
235
|
+
free: plan({ price: usd(0), default: true, includes: { llm_usage: limit("$5") } }),
|
|
236
|
+
legacy: plan({
|
|
237
|
+
price: usd(20),
|
|
238
|
+
interval: "month",
|
|
239
|
+
deprecated: true,
|
|
240
|
+
includes: { llm_usage: limit("$25") },
|
|
241
|
+
}),
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
it("defaults to false so an ordinary plan is unchanged", () => {
|
|
247
|
+
for (const p of fixture().plans) expect(p.deprecated).toBe(false);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("normalizes the flag onto the plan", () => {
|
|
251
|
+
const plans = withRetired().plans;
|
|
252
|
+
expect(plans.find((p) => p.key === "legacy")!.deprecated).toBe(true);
|
|
253
|
+
expect(plans.find((p) => p.key === "free")!.deprecated).toBe(false);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("refuses to retire the default plan — new customers land on it", () => {
|
|
257
|
+
expect(() =>
|
|
258
|
+
defineBilling({
|
|
259
|
+
provider: stripe(),
|
|
260
|
+
credit: { unit: "usd", peg: usd(1) },
|
|
261
|
+
features: { llm_usage: { kind: "metered" } },
|
|
262
|
+
plans: {
|
|
263
|
+
free: plan({ price: usd(0), default: true, deprecated: true }),
|
|
264
|
+
},
|
|
265
|
+
}),
|
|
266
|
+
).toThrow(/default plan "free" cannot be deprecated/);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Retiring a plan changes nothing the provider bills and nothing the ledger
|
|
270
|
+
// meters, so it must not look like a price change — a churned hash would mint
|
|
271
|
+
// a new Stripe product for a plan we are trying to stop selling.
|
|
272
|
+
it("does not touch either hash", () => {
|
|
273
|
+
const before = fixture().plans.find((p) => p.key === "pro")!;
|
|
274
|
+
expect(planHash({ ...before, deprecated: true })).toBe(planHash(before));
|
|
275
|
+
const serialized = toCatalog(withRetired()).plans.find((p) => p.key === "legacy")!;
|
|
276
|
+
expect(serialized.deprecated).toBe(true);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// Omitted-when-false keeps an all-current catalog byte-identical to what the
|
|
280
|
+
// serializer emitted before this field existed.
|
|
281
|
+
it("omits the flag entirely for a current plan", () => {
|
|
282
|
+
expect(serializeCatalog(fixture())).not.toContain("deprecated");
|
|
283
|
+
expect(serializeCatalog(withRetired())).toContain('"deprecated":true');
|
|
284
|
+
});
|
|
285
|
+
});
|