@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.
- package/dist/catalog.mjs +127 -0
- package/dist/index.mjs +152 -0
- package/dist/react.mjs +134 -0
- package/package.json +44 -0
- package/src/catalog.ts +194 -0
- package/src/index.ts +412 -0
- package/src/react.tsx +175 -0
- package/src/test/billing.test.ts +226 -0
package/dist/catalog.mjs
ADDED
|
@@ -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
|
+
}
|