@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,127 @@
1
+ //#region src/catalog.ts
2
+ /** Recursively sort object keys so JSON.stringify is order-independent. */
3
+ function sortDeep(value) {
4
+ if (Array.isArray(value)) return value.map(sortDeep);
5
+ if (value && typeof value === "object") {
6
+ const out = {};
7
+ for (const k of Object.keys(value).sort()) {
8
+ const v = value[k];
9
+ if (v !== void 0) out[k] = sortDeep(v);
10
+ }
11
+ return out;
12
+ }
13
+ return value;
14
+ }
15
+ /** Canonical stringify: sorted keys, undefined dropped, no incidental whitespace. */
16
+ function stableStringify(value) {
17
+ return JSON.stringify(sortDeep(value));
18
+ }
19
+ function fnv1a(str) {
20
+ let h = 2166136261;
21
+ for (let i = 0; i < str.length; i++) {
22
+ h ^= str.charCodeAt(i);
23
+ h = h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) >>> 0;
24
+ }
25
+ return h.toString(16).padStart(8, "0");
26
+ }
27
+ /** The billable shape of a plan — only fields that, if changed, require a new
28
+ * provider Price (immutable) or entitlement migration. label/description are
29
+ * cosmetic and excluded so copy edits don't churn the provider. */
30
+ function planHashInput(plan) {
31
+ return {
32
+ price: plan.price,
33
+ interval: plan.interval,
34
+ inclusions: plan.inclusions.map((i) => ({
35
+ feature: i.feature,
36
+ kind: i.kind,
37
+ allowance: i.allowance,
38
+ enabled: i.enabled,
39
+ window: i.window,
40
+ overage: i.overage ? { allow: i.overage.allow } : void 0
41
+ }))
42
+ };
43
+ }
44
+ function planHash(plan) {
45
+ return fnv1a(stableStringify(planHashInput(plan)));
46
+ }
47
+ /** Hash of ONLY what the payment provider needs (price + interval). The
48
+ * reconciler keys on this so allowance/window/overage edits — which our own
49
+ * ledger enforces, not the provider — never mint a new provider product. */
50
+ function planPriceHash(plan) {
51
+ return fnv1a(stableStringify({
52
+ price: plan.price,
53
+ interval: plan.interval
54
+ }));
55
+ }
56
+ function addOnHashInput(addOn) {
57
+ return {
58
+ price: addOn.price,
59
+ interval: addOn.interval,
60
+ unit: addOn.unit,
61
+ sizeMultipliers: addOn.sizeMultipliers,
62
+ metadata: addOn.metadata
63
+ };
64
+ }
65
+ function addOnHash(addOn) {
66
+ return fnv1a(stableStringify(addOnHashInput(addOn)));
67
+ }
68
+ function addOnPriceHash(addOn) {
69
+ return fnv1a(stableStringify({
70
+ price: addOn.price,
71
+ interval: addOn.interval
72
+ }));
73
+ }
74
+ function toCatalog(billing) {
75
+ return {
76
+ version: 1,
77
+ provider: {
78
+ name: billing.provider.name,
79
+ useProviderMeters: false
80
+ },
81
+ credit: billing.credit,
82
+ features: billing.features.map((f) => ({
83
+ key: f.key,
84
+ kind: f.kind,
85
+ label: f.label ?? f.key,
86
+ unit: f.unit
87
+ })),
88
+ rateCards: billing.rateCards,
89
+ plans: billing.plans.map((p) => ({
90
+ key: p.key,
91
+ label: p.label,
92
+ description: p.description,
93
+ price: p.price,
94
+ interval: p.interval,
95
+ default: p.default,
96
+ inclusions: p.inclusions.map((i) => ({
97
+ feature: i.feature,
98
+ kind: i.kind,
99
+ allowance: i.allowance,
100
+ enabled: i.enabled,
101
+ window: i.window,
102
+ overage: i.overage ? { allow: i.overage.allow } : void 0
103
+ })),
104
+ hash: planHash(p),
105
+ priceHash: planPriceHash(p)
106
+ })),
107
+ addOns: billing.addOns.map((a) => ({
108
+ key: a.key,
109
+ label: a.label,
110
+ description: a.description,
111
+ price: a.price,
112
+ interval: a.interval,
113
+ unit: a.unit,
114
+ sizeMultipliers: a.sizeMultipliers,
115
+ metadata: a.metadata,
116
+ hash: addOnHash(a),
117
+ priceHash: addOnPriceHash(a)
118
+ })),
119
+ grants: billing.grants
120
+ };
121
+ }
122
+ /** Emit the canonical catalog string the Go reconciler reads. */
123
+ function serializeCatalog(billing) {
124
+ return stableStringify(toCatalog(billing));
125
+ }
126
+ //#endregion
127
+ export { addOnHash, addOnPriceHash, fnv1a, planHash, planPriceHash, serializeCatalog, stableStringify, toCatalog };
package/dist/index.mjs ADDED
@@ -0,0 +1,152 @@
1
+ //#region src/index.ts
2
+ const MICROS_PER_UNIT = 1e6;
3
+ /** Dollars (or whole credit units) → integer micro-units. usd(0.000015) → 15. */
4
+ function usd(amount) {
5
+ return Math.round(amount * MICROS_PER_UNIT);
6
+ }
7
+ /** Parse a "$5" / "$0.25" / "5" string, or pass Money through unchanged. */
8
+ function money(input) {
9
+ if (typeof input === "number") return input;
10
+ const cleaned = input.trim().replace(/^\$/, "").replace(/,/g, "");
11
+ const n = Number(cleaned);
12
+ if (!Number.isFinite(n)) throw new Error(`@omg-dev/billing: cannot parse money value ${JSON.stringify(input)}`);
13
+ return usd(n);
14
+ }
15
+ /** Render µ-units back to a "$X.XX" string for the UI. */
16
+ function formatMoney(m, opts = {}) {
17
+ const symbol = opts.symbol ?? "$";
18
+ const v = m / MICROS_PER_UNIT;
19
+ return `${symbol}${Number.isInteger(v) ? v.toString() : trimFloat(v)}`;
20
+ }
21
+ function trimFloat(v) {
22
+ return v.toFixed(6).replace(/0+$/, "").replace(/\.$/, "");
23
+ }
24
+ function limit(amount, opts = {}) {
25
+ return {
26
+ __kind: "limit",
27
+ amount: money(amount),
28
+ window: opts.per
29
+ };
30
+ }
31
+ function overage(opts = {}) {
32
+ return {
33
+ __kind: "overage",
34
+ allow: opts.allow ?? true
35
+ };
36
+ }
37
+ function rateCard(models) {
38
+ return {
39
+ __kind: "rateCard",
40
+ models
41
+ };
42
+ }
43
+ function stripe() {
44
+ return {
45
+ name: "stripe",
46
+ useProviderMeters: false
47
+ };
48
+ }
49
+ function polar() {
50
+ return {
51
+ name: "polar",
52
+ useProviderMeters: false
53
+ };
54
+ }
55
+ function plan(input) {
56
+ return {
57
+ __kind: "plan",
58
+ ...input
59
+ };
60
+ }
61
+ function addOn(input) {
62
+ return {
63
+ __kind: "addOn",
64
+ ...input
65
+ };
66
+ }
67
+ function titleCase(key) {
68
+ return key.split(/[_\s-]+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
69
+ }
70
+ function defineBilling(config) {
71
+ if (new Set(Object.keys(config.features)).size === 0) throw new Error("@omg-dev/billing: at least one feature must be declared");
72
+ const planKeys = Object.keys(config.plans);
73
+ if (planKeys.length === 0) throw new Error("@omg-dev/billing: at least one plan must be declared");
74
+ const defaults = planKeys.filter((k) => config.plans[k].default);
75
+ if (defaults.length !== 1) throw new Error(`@omg-dev/billing: exactly one plan must be default:true (found ${defaults.length}: [${defaults.join(", ")}])`);
76
+ const rateCards = config.usage ?? {};
77
+ for (const fkey of Object.keys(rateCards)) {
78
+ const f = config.features[fkey];
79
+ if (!f) throw new Error(`@omg-dev/billing: rate card references unknown feature "${fkey}"`);
80
+ if (f.kind !== "metered") throw new Error(`@omg-dev/billing: rate card on non-metered feature "${fkey}"`);
81
+ }
82
+ const grants = Object.values(config.grants ?? {});
83
+ for (const g of grants) {
84
+ const f = config.features[g.feature];
85
+ if (!f) throw new Error(`@omg-dev/billing: grant references unknown feature "${g.feature}"`);
86
+ if (f.kind !== "metered") throw new Error(`@omg-dev/billing: grant on non-metered feature "${g.feature}"`);
87
+ }
88
+ const features = Object.entries(config.features).map(([key, def]) => ({
89
+ key,
90
+ ...def,
91
+ label: def.label ?? titleCase(key)
92
+ }));
93
+ const plans = Object.entries(config.plans).map(([key, p]) => {
94
+ const includes = p.includes ?? {};
95
+ const usage = p.usage ?? {};
96
+ const inclusions = Object.entries(includes).map(([feature, inc]) => {
97
+ const f = config.features[feature];
98
+ if (!f) throw new Error(`@omg-dev/billing: plan "${key}" includes unknown feature "${feature}"`);
99
+ if (f.kind === "metered") {
100
+ if (typeof inc === "boolean") throw new Error(`@omg-dev/billing: plan "${key}" feature "${feature}" is metered but got a boolean inclusion`);
101
+ return {
102
+ feature,
103
+ kind: "metered",
104
+ allowance: inc.amount,
105
+ window: inc.window,
106
+ overage: usage[feature]
107
+ };
108
+ }
109
+ if (typeof inc !== "boolean") throw new Error(`@omg-dev/billing: plan "${key}" feature "${feature}" is boolean but got a limit inclusion`);
110
+ return {
111
+ feature,
112
+ kind: "boolean",
113
+ enabled: inc
114
+ };
115
+ });
116
+ return {
117
+ key,
118
+ label: p.label ?? titleCase(key),
119
+ description: p.description,
120
+ price: p.price,
121
+ interval: p.interval,
122
+ default: p.default ?? false,
123
+ inclusions
124
+ };
125
+ });
126
+ const addOns = Object.entries(config.addOns ?? {}).map(([key, a]) => {
127
+ if (!Number.isInteger(a.price) || a.price < 0) throw new Error(`@omg-dev/billing: add-on "${key}" price must be a non-negative integer`);
128
+ if (a.interval !== "month" && a.interval !== "year") throw new Error(`@omg-dev/billing: add-on "${key}" interval must be month or year`);
129
+ return {
130
+ key,
131
+ label: a.label ?? titleCase(key),
132
+ description: a.description,
133
+ price: a.price,
134
+ interval: a.interval,
135
+ unit: a.unit,
136
+ sizeMultipliers: a.sizeMultipliers,
137
+ metadata: a.metadata
138
+ };
139
+ });
140
+ return {
141
+ provider: config.provider,
142
+ credit: config.credit,
143
+ features,
144
+ plans,
145
+ addOns,
146
+ rateCards,
147
+ grants,
148
+ config
149
+ };
150
+ }
151
+ //#endregion
152
+ export { MICROS_PER_UNIT, addOn, defineBilling, formatMoney, limit, money, overage, plan, polar, rateCard, stripe, usd };
package/dist/react.mjs ADDED
@@ -0,0 +1,134 @@
1
+ import { formatMoney } from "./index.mjs";
2
+ import * as React from "react";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ //#region src/react.tsx
5
+ function intervalSuffix(interval) {
6
+ if (!interval) return "";
7
+ return interval === "month" ? "/mo" : "/yr";
8
+ }
9
+ function priceLabel(price, interval) {
10
+ if (price === 0) return "Free";
11
+ return `${formatMoney(price)}${intervalSuffix(interval)}`;
12
+ }
13
+ /** One human-readable line per inclusion, derived from the normalized plan. */
14
+ function inclusionLines(plan, billing, renderInclusion) {
15
+ const labelFor = (key) => billing.features.find((f) => f.key === key)?.label ?? key;
16
+ const lines = [];
17
+ for (const inc of plan.inclusions) {
18
+ const override = renderInclusion?.(inc, {
19
+ plan,
20
+ billing
21
+ });
22
+ if (override != null) {
23
+ lines.push(override);
24
+ continue;
25
+ }
26
+ if (inc.kind === "metered") {
27
+ let line = `${inc.allowance != null ? formatMoney(inc.allowance) : "—"} ${labelFor(inc.feature)}`;
28
+ if (inc.overage?.allow) line += ", then pay-as-you-go";
29
+ lines.push(line);
30
+ } else if (inc.enabled) lines.push(labelFor(inc.feature));
31
+ }
32
+ return lines;
33
+ }
34
+ function PricingTable({ billing, currentPlan, onSelectPlan, ctaLabel, renderInclusion, className, sortByPrice = true }) {
35
+ return /* @__PURE__ */ jsx("div", {
36
+ className,
37
+ style: {
38
+ display: "grid",
39
+ gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
40
+ gap: "1rem"
41
+ },
42
+ "data-vibes-pricing-table": true,
43
+ children: React.useMemo(() => {
44
+ const list = [...billing.plans];
45
+ if (sortByPrice) list.sort((a, b) => a.price - b.price);
46
+ return list;
47
+ }, [billing.plans, sortByPrice]).map((plan) => {
48
+ const isCurrent = currentPlan === plan.key;
49
+ const lines = inclusionLines(plan, billing, renderInclusion);
50
+ return /* @__PURE__ */ jsxs("div", {
51
+ "data-plan": plan.key,
52
+ "data-default": plan.default || void 0,
53
+ style: {
54
+ border: "1px solid var(--border, #e5e7eb)",
55
+ borderRadius: "0.75rem",
56
+ padding: "1.25rem",
57
+ display: "flex",
58
+ flexDirection: "column",
59
+ gap: "0.75rem"
60
+ },
61
+ children: [
62
+ /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("div", {
63
+ style: {
64
+ fontWeight: 600,
65
+ fontSize: "1rem"
66
+ },
67
+ children: plan.label
68
+ }), plan.description ? /* @__PURE__ */ jsx("div", {
69
+ style: {
70
+ fontSize: "0.8125rem",
71
+ opacity: .7
72
+ },
73
+ children: plan.description
74
+ }) : null] }),
75
+ /* @__PURE__ */ jsx("div", {
76
+ style: {
77
+ fontSize: "1.5rem",
78
+ fontWeight: 700
79
+ },
80
+ children: priceLabel(plan.price, plan.interval)
81
+ }),
82
+ /* @__PURE__ */ jsx("ul", {
83
+ style: {
84
+ listStyle: "none",
85
+ padding: 0,
86
+ margin: 0,
87
+ display: "grid",
88
+ gap: "0.375rem",
89
+ flex: 1
90
+ },
91
+ children: lines.map((line, i) => /* @__PURE__ */ jsxs("li", {
92
+ style: {
93
+ fontSize: "0.875rem",
94
+ display: "flex",
95
+ gap: "0.5rem"
96
+ },
97
+ children: [/* @__PURE__ */ jsx("span", {
98
+ "aria-hidden": true,
99
+ children: "✓"
100
+ }), /* @__PURE__ */ jsx("span", { children: line })]
101
+ }, i))
102
+ }),
103
+ isCurrent ? /* @__PURE__ */ jsx("div", {
104
+ style: {
105
+ textAlign: "center",
106
+ fontSize: "0.875rem",
107
+ fontWeight: 600,
108
+ opacity: .7,
109
+ padding: "0.5rem"
110
+ },
111
+ "data-current": true,
112
+ children: "Current plan"
113
+ }) : onSelectPlan ? /* @__PURE__ */ jsx("button", {
114
+ type: "button",
115
+ onClick: () => onSelectPlan(plan.key),
116
+ style: {
117
+ cursor: "pointer",
118
+ border: "1px solid var(--border, #e5e7eb)",
119
+ borderRadius: "0.5rem",
120
+ padding: "0.5rem 0.75rem",
121
+ fontSize: "0.875rem",
122
+ fontWeight: 600,
123
+ background: plan.default ? "var(--primary, #111)" : "transparent",
124
+ color: plan.default ? "var(--primary-foreground, #fff)" : "inherit"
125
+ },
126
+ children: ctaLabel ? ctaLabel(plan) : `Choose ${plan.label}`
127
+ }) : null
128
+ ]
129
+ }, plan.key);
130
+ })
131
+ });
132
+ }
133
+ //#endregion
134
+ export { PricingTable };
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@omg-dev/billing",
3
+ "version": "0.4.24",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./src/index.ts",
8
+ "default": "./dist/index.mjs"
9
+ },
10
+ "./react": {
11
+ "types": "./src/react.tsx",
12
+ "default": "./dist/react.mjs"
13
+ },
14
+ "./catalog": {
15
+ "types": "./src/catalog.ts",
16
+ "default": "./dist/catalog.mjs"
17
+ }
18
+ },
19
+ "scripts": {
20
+ "test": "vp test run"
21
+ },
22
+ "peerDependencies": {
23
+ "react": "^18 || ^19"
24
+ },
25
+ "peerDependenciesMeta": {
26
+ "react": {
27
+ "optional": true
28
+ }
29
+ },
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/BennyKok/vibes.git"
34
+ },
35
+ "homepage": "https://docs.omg.dev",
36
+ "files": [
37
+ "dist",
38
+ "src"
39
+ ],
40
+ "publishConfig": {
41
+ "access": "public",
42
+ "registry": "https://registry.npmjs.org/"
43
+ }
44
+ }
package/src/catalog.ts ADDED
@@ -0,0 +1,194 @@
1
+ // Catalog serializer: Billing → canonical JSON the Go reconciler consumes.
2
+ //
3
+ // Go cannot execute the TS defineBilling declaration, so we emit a stable,
4
+ // hashable catalog. Determinism matters: the per-plan `hash` is what the
5
+ // reconciler diffs to decide create / update / version-bump / archive, so the
6
+ // same config MUST always serialize byte-identically.
7
+
8
+ import type {
9
+ Billing,
10
+ GrantDef,
11
+ Money,
12
+ NormalizedAddOn,
13
+ NormalizedPlan,
14
+ RateCard,
15
+ } from "./index.ts";
16
+
17
+ export interface CatalogPlan {
18
+ key: string;
19
+ label: string;
20
+ description?: string;
21
+ price: Money;
22
+ interval?: "month" | "year";
23
+ default: boolean;
24
+ inclusions: Array<{
25
+ feature: string;
26
+ kind: "metered" | "boolean";
27
+ allowance?: Money;
28
+ enabled?: boolean;
29
+ window?: "day" | "week" | "month";
30
+ overage?: { allow: boolean };
31
+ }>;
32
+ /** Stable hash of this plan's FULL billable shape (price + interval +
33
+ * inclusions + windows). Reference / change-detection. */
34
+ hash: string;
35
+ /** Hash of ONLY the provider-relevant shape (price + interval). The Stripe/
36
+ * Polar reconciler diffs on THIS, so changing an allowance/window/overage is
37
+ * a pure ledger config change that never churns a provider product — only a
38
+ * real price change does. */
39
+ priceHash: string;
40
+ }
41
+
42
+ export interface CatalogAddOn {
43
+ key: string;
44
+ label: string;
45
+ description?: string;
46
+ price: Money;
47
+ interval: "month" | "year";
48
+ unit?: string;
49
+ sizeMultipliers?: Record<string, number>;
50
+ metadata?: Record<string, string>;
51
+ hash: string;
52
+ priceHash: string;
53
+ }
54
+
55
+ export interface Catalog {
56
+ version: 1;
57
+ provider: { name: string; useProviderMeters: false };
58
+ credit: { unit: string; peg: Money };
59
+ features: Array<{ key: string; kind: string; label: string; unit?: string }>;
60
+ rateCards: Record<string, RateCard>;
61
+ plans: CatalogPlan[];
62
+ addOns: CatalogAddOn[];
63
+ grants: GrantDef[];
64
+ }
65
+
66
+ /** Recursively sort object keys so JSON.stringify is order-independent. */
67
+ function sortDeep(value: unknown): unknown {
68
+ if (Array.isArray(value)) return value.map(sortDeep);
69
+ if (value && typeof value === "object") {
70
+ const out: Record<string, unknown> = {};
71
+ for (const k of Object.keys(value as Record<string, unknown>).sort()) {
72
+ const v = (value as Record<string, unknown>)[k];
73
+ if (v !== undefined) out[k] = sortDeep(v);
74
+ }
75
+ return out;
76
+ }
77
+ return value;
78
+ }
79
+
80
+ /** Canonical stringify: sorted keys, undefined dropped, no incidental whitespace. */
81
+ export function stableStringify(value: unknown): string {
82
+ return JSON.stringify(sortDeep(value));
83
+ }
84
+
85
+ // FNV-1a 32-bit — small, dependency-free, stable across TS and Go.
86
+ export function fnv1a(str: string): string {
87
+ let h = 0x811c9dc5;
88
+ for (let i = 0; i < str.length; i++) {
89
+ h ^= str.charCodeAt(i);
90
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
91
+ }
92
+ return h.toString(16).padStart(8, "0");
93
+ }
94
+
95
+ /** The billable shape of a plan — only fields that, if changed, require a new
96
+ * provider Price (immutable) or entitlement migration. label/description are
97
+ * cosmetic and excluded so copy edits don't churn the provider. */
98
+ function planHashInput(plan: NormalizedPlan) {
99
+ return {
100
+ price: plan.price,
101
+ interval: plan.interval,
102
+ inclusions: plan.inclusions.map((i) => ({
103
+ feature: i.feature,
104
+ kind: i.kind,
105
+ allowance: i.allowance,
106
+ enabled: i.enabled,
107
+ window: i.window,
108
+ overage: i.overage ? { allow: i.overage.allow } : undefined,
109
+ })),
110
+ };
111
+ }
112
+
113
+ export function planHash(plan: NormalizedPlan): string {
114
+ return fnv1a(stableStringify(planHashInput(plan)));
115
+ }
116
+
117
+ /** Hash of ONLY what the payment provider needs (price + interval). The
118
+ * reconciler keys on this so allowance/window/overage edits — which our own
119
+ * ledger enforces, not the provider — never mint a new provider product. */
120
+ export function planPriceHash(plan: NormalizedPlan): string {
121
+ return fnv1a(stableStringify({ price: plan.price, interval: plan.interval }));
122
+ }
123
+
124
+ function addOnHashInput(addOn: NormalizedAddOn) {
125
+ return {
126
+ price: addOn.price,
127
+ interval: addOn.interval,
128
+ unit: addOn.unit,
129
+ sizeMultipliers: addOn.sizeMultipliers,
130
+ metadata: addOn.metadata,
131
+ };
132
+ }
133
+
134
+ export function addOnHash(addOn: NormalizedAddOn): string {
135
+ return fnv1a(stableStringify(addOnHashInput(addOn)));
136
+ }
137
+
138
+ export function addOnPriceHash(addOn: NormalizedAddOn): string {
139
+ return fnv1a(stableStringify({ price: addOn.price, interval: addOn.interval }));
140
+ }
141
+
142
+ export function toCatalog(billing: Billing): Catalog {
143
+ return {
144
+ version: 1,
145
+ provider: {
146
+ name: billing.provider.name,
147
+ useProviderMeters: false,
148
+ },
149
+ credit: billing.credit,
150
+ features: billing.features.map((f) => ({
151
+ key: f.key,
152
+ kind: f.kind,
153
+ label: f.label ?? f.key,
154
+ unit: f.unit,
155
+ })),
156
+ rateCards: billing.rateCards,
157
+ plans: billing.plans.map((p) => ({
158
+ key: p.key,
159
+ label: p.label,
160
+ description: p.description,
161
+ price: p.price,
162
+ interval: p.interval,
163
+ default: p.default,
164
+ inclusions: p.inclusions.map((i) => ({
165
+ feature: i.feature,
166
+ kind: i.kind,
167
+ allowance: i.allowance,
168
+ enabled: i.enabled,
169
+ window: i.window,
170
+ overage: i.overage ? { allow: i.overage.allow } : undefined,
171
+ })),
172
+ hash: planHash(p),
173
+ priceHash: planPriceHash(p),
174
+ })),
175
+ addOns: billing.addOns.map((a) => ({
176
+ key: a.key,
177
+ label: a.label,
178
+ description: a.description,
179
+ price: a.price,
180
+ interval: a.interval,
181
+ unit: a.unit,
182
+ sizeMultipliers: a.sizeMultipliers,
183
+ metadata: a.metadata,
184
+ hash: addOnHash(a),
185
+ priceHash: addOnPriceHash(a),
186
+ })),
187
+ grants: billing.grants,
188
+ };
189
+ }
190
+
191
+ /** Emit the canonical catalog string the Go reconciler reads. */
192
+ export function serializeCatalog(billing: Billing): string {
193
+ return stableStringify(toCatalog(billing));
194
+ }