@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 CHANGED
@@ -93,6 +93,7 @@ function toCatalog(billing) {
93
93
  price: p.price,
94
94
  interval: p.interval,
95
95
  default: p.default,
96
+ deprecated: p.deprecated ? true : void 0,
96
97
  inclusions: p.inclusions.map((i) => ({
97
98
  feature: i.feature,
98
99
  kind: i.kind,
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 = [...billing.plans];
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
- }, [billing.plans, sortByPrice]).map((plan) => {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omg-dev/billing",
3
- "version": "0.4.33",
3
+ "version": "0.4.35",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
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
- const list = [...billing.plans];
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
@@ -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
+ });