@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/src/index.ts
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
// @omg-dev/billing — code-declared, framework-reconciled billing.
|
|
2
|
+
//
|
|
3
|
+
// The declaration in this file is the SOURCE OF TRUTH ("code wins"). The Go
|
|
4
|
+
// reconciler projects it into a connected provider (Stripe/Polar); the React
|
|
5
|
+
// <PricingTable> renders from the same object. Money is integer MICRO-UNITS of
|
|
6
|
+
// the tenant credit (µ-unit), so there is never a float in the ledger.
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Money — integer micro-units. usd(1) === 1_000_000. Never a float at rest.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
export type Money = number & { readonly __brand: "Money" };
|
|
13
|
+
|
|
14
|
+
export const MICROS_PER_UNIT = 1_000_000;
|
|
15
|
+
|
|
16
|
+
/** Dollars (or whole credit units) → integer micro-units. usd(0.000015) → 15. */
|
|
17
|
+
export function usd(amount: number): Money {
|
|
18
|
+
return Math.round(amount * MICROS_PER_UNIT) as Money;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Parse a "$5" / "$0.25" / "5" string, or pass Money through unchanged. */
|
|
22
|
+
export function money(input: Money | string | number): Money {
|
|
23
|
+
if (typeof input === "number") return input as Money;
|
|
24
|
+
const cleaned = input.trim().replace(/^\$/, "").replace(/,/g, "");
|
|
25
|
+
const n = Number(cleaned);
|
|
26
|
+
if (!Number.isFinite(n)) {
|
|
27
|
+
throw new Error(`@omg-dev/billing: cannot parse money value ${JSON.stringify(input)}`);
|
|
28
|
+
}
|
|
29
|
+
return usd(n);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Render µ-units back to a "$X.XX" string for the UI. */
|
|
33
|
+
export function formatMoney(m: Money, opts: { symbol?: string } = {}): string {
|
|
34
|
+
const symbol = opts.symbol ?? "$";
|
|
35
|
+
const v = m / MICROS_PER_UNIT;
|
|
36
|
+
// whole numbers render without cents; fractional keeps up to 6 sig digits
|
|
37
|
+
const str = Number.isInteger(v) ? v.toString() : trimFloat(v);
|
|
38
|
+
return `${symbol}${str}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function trimFloat(v: number): string {
|
|
42
|
+
return v.toFixed(6).replace(/0+$/, "").replace(/\.$/, "");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Features — what a plan can include / meter.
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export type FeatureKind = "metered" | "boolean";
|
|
50
|
+
|
|
51
|
+
export interface FeatureDef {
|
|
52
|
+
kind: FeatureKind;
|
|
53
|
+
/** Human label for the pricing UI. Defaults to a title-cased key. */
|
|
54
|
+
label?: string;
|
|
55
|
+
/** Unit noun shown in the UI for metered features ("tokens", "builds"). */
|
|
56
|
+
unit?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Plan inclusions / overage policy.
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Recurring window a limit resets on. Omitted = the plan's billing period
|
|
65
|
+
* (monthly). "day"/"week"/"month" → the allowance is a windowed hard-cap that
|
|
66
|
+
* refills each calendar window (use-it-or-lose-it), like a Claude usage limit.
|
|
67
|
+
*/
|
|
68
|
+
export type BillingWindow = "day" | "week" | "month";
|
|
69
|
+
|
|
70
|
+
/** A metered allowance bundled into a plan (e.g. $5 of llm_usage per week). */
|
|
71
|
+
export interface Limit {
|
|
72
|
+
readonly __kind: "limit";
|
|
73
|
+
amount: Money;
|
|
74
|
+
/** Reset cadence. Undefined → granted once per billing period (legacy
|
|
75
|
+
* monthly). Set → a windowed limit that refills every calendar window. */
|
|
76
|
+
window?: BillingWindow;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Overage policy once the included allowance is exhausted. */
|
|
80
|
+
export interface Overage {
|
|
81
|
+
readonly __kind: "overage";
|
|
82
|
+
/** If false, hard-stop at the limit. If true, meter beyond at rate-card price. */
|
|
83
|
+
allow: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function limit(
|
|
87
|
+
amount: Money | string | number,
|
|
88
|
+
opts: { per?: BillingWindow } = {},
|
|
89
|
+
): Limit {
|
|
90
|
+
return { __kind: "limit", amount: money(amount), window: opts.per };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function overage(opts: { allow?: boolean } = {}): Overage {
|
|
94
|
+
return { __kind: "overage", allow: opts.allow ?? true };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A per-feature inclusion: a metered allowance, or a boolean entitlement. */
|
|
98
|
+
export type Inclusion = Limit | boolean;
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Rate card — synthetic PRICE per metered unit (NOT pass-through cost).
|
|
102
|
+
// Per-model token pricing; cache buckets are first-class (see llm.go).
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
export interface ModelRate {
|
|
106
|
+
in: Money;
|
|
107
|
+
out: Money;
|
|
108
|
+
cacheRead?: Money;
|
|
109
|
+
cacheCreation?: Money;
|
|
110
|
+
/** Lowest plan key allowed to use this model. Omitted = default plan. */
|
|
111
|
+
minPlan?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface RateCard {
|
|
115
|
+
readonly __kind: "rateCard";
|
|
116
|
+
models: Record<string, ModelRate>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function rateCard(models: Record<string, ModelRate>): RateCard {
|
|
120
|
+
return { __kind: "rateCard", models };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Provider descriptors.
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
export type ProviderName = "stripe" | "polar";
|
|
128
|
+
|
|
129
|
+
export interface ProviderDef {
|
|
130
|
+
name: ProviderName;
|
|
131
|
+
/** Use provider-side meters? We deliberately keep this false — our ledger meters. */
|
|
132
|
+
useProviderMeters: false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function stripe(): ProviderDef {
|
|
136
|
+
return { name: "stripe", useProviderMeters: false };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function polar(): ProviderDef {
|
|
140
|
+
return { name: "polar", useProviderMeters: false };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Plan definition (authoring shape).
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
export interface PlanInput {
|
|
148
|
+
/** Recurring price. usd(0) for a free plan. */
|
|
149
|
+
price: Money;
|
|
150
|
+
interval?: "month" | "year";
|
|
151
|
+
/** Exactly one plan should set default:true (the plan new customers land on). */
|
|
152
|
+
default?: boolean;
|
|
153
|
+
label?: string;
|
|
154
|
+
description?: string;
|
|
155
|
+
/** Per-feature inclusions: feature key → Limit | boolean. */
|
|
156
|
+
includes?: Record<string, Inclusion>;
|
|
157
|
+
/** Per-feature overage policy once the inclusion is exhausted. */
|
|
158
|
+
usage?: Record<string, Overage>;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface PlanDef extends PlanInput {
|
|
162
|
+
readonly __kind: "plan";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function plan(input: PlanInput): PlanDef {
|
|
166
|
+
return { __kind: "plan", ...input };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Add-ons — recurring provider items that sit beside the base plan.
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
export interface AddOnInput {
|
|
174
|
+
price: Money;
|
|
175
|
+
interval: "month" | "year";
|
|
176
|
+
label?: string;
|
|
177
|
+
description?: string;
|
|
178
|
+
unit?: string;
|
|
179
|
+
/**
|
|
180
|
+
* Optional size multipliers for add-ons whose quantity is weighted by an
|
|
181
|
+
* app/runtime size. The engine does not interpret these; platform code reads
|
|
182
|
+
* them from the catalog so rate-card config remains code-owned.
|
|
183
|
+
*/
|
|
184
|
+
sizeMultipliers?: Record<string, number>;
|
|
185
|
+
/**
|
|
186
|
+
* Extra stable config for platform-owned add-ons, e.g. the VM scaling mode
|
|
187
|
+
* this add-on prices. Kept stringly so the SDK stays provider-agnostic.
|
|
188
|
+
*/
|
|
189
|
+
metadata?: Record<string, string>;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface AddOnDef extends AddOnInput {
|
|
193
|
+
readonly __kind: "addOn";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function addOn(input: AddOnInput): AddOnDef {
|
|
197
|
+
return { __kind: "addOn", ...input };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Grants — automatic credit grants on a lifecycle event.
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
export interface GrantDef {
|
|
205
|
+
/** Lifecycle trigger, e.g. "customer.created" for the signup grant. */
|
|
206
|
+
on: string;
|
|
207
|
+
amount: Money;
|
|
208
|
+
/** Which metered feature the grant funds. */
|
|
209
|
+
feature: string;
|
|
210
|
+
/** Optional: expire the grant N days after issue (undefined = never). */
|
|
211
|
+
expiresInDays?: number;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Top-level config.
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
export interface BillingConfig {
|
|
219
|
+
provider: ProviderDef;
|
|
220
|
+
/** Credit unit + peg. omg: { unit: "usd", peg: usd(1) } => 1 credit = $1. */
|
|
221
|
+
credit: { unit: string; peg: Money };
|
|
222
|
+
features: Record<string, FeatureDef>;
|
|
223
|
+
usage?: Record<string, RateCard>;
|
|
224
|
+
plans: Record<string, PlanDef>;
|
|
225
|
+
addOns?: Record<string, AddOnDef>;
|
|
226
|
+
grants?: Record<string, GrantDef>;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// Normalized, introspectable Billing object — the single shape the serializer
|
|
231
|
+
// AND the <PricingTable> read from. defineBilling validates + normalizes.
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
export interface NormalizedFeature extends FeatureDef {
|
|
235
|
+
key: string;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface NormalizedPlanInclusion {
|
|
239
|
+
feature: string;
|
|
240
|
+
/** "metered" inclusion carries an allowance; "boolean" carries enabled. */
|
|
241
|
+
kind: FeatureKind;
|
|
242
|
+
allowance?: Money;
|
|
243
|
+
enabled?: boolean;
|
|
244
|
+
/** Reset window for a metered allowance (undefined = monthly billing period). */
|
|
245
|
+
window?: BillingWindow;
|
|
246
|
+
/** Overage policy for this feature on this plan, if any. */
|
|
247
|
+
overage?: Overage;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface NormalizedPlan {
|
|
251
|
+
key: string;
|
|
252
|
+
label: string;
|
|
253
|
+
description?: string;
|
|
254
|
+
price: Money;
|
|
255
|
+
interval?: "month" | "year";
|
|
256
|
+
default: boolean;
|
|
257
|
+
inclusions: NormalizedPlanInclusion[];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface NormalizedAddOn {
|
|
261
|
+
key: string;
|
|
262
|
+
label: string;
|
|
263
|
+
description?: string;
|
|
264
|
+
price: Money;
|
|
265
|
+
interval: "month" | "year";
|
|
266
|
+
unit?: string;
|
|
267
|
+
sizeMultipliers?: Record<string, number>;
|
|
268
|
+
metadata?: Record<string, string>;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export interface Billing {
|
|
272
|
+
provider: ProviderDef;
|
|
273
|
+
credit: { unit: string; peg: Money };
|
|
274
|
+
features: NormalizedFeature[];
|
|
275
|
+
plans: NormalizedPlan[];
|
|
276
|
+
addOns: NormalizedAddOn[];
|
|
277
|
+
rateCards: Record<string, RateCard>;
|
|
278
|
+
grants: GrantDef[];
|
|
279
|
+
/** The raw validated config, for callers that want the authoring shape. */
|
|
280
|
+
config: BillingConfig;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function titleCase(key: string): string {
|
|
284
|
+
return key
|
|
285
|
+
.split(/[_\s-]+/)
|
|
286
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
287
|
+
.join(" ");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function defineBilling(config: BillingConfig): Billing {
|
|
291
|
+
// --- validation (fail loud; never silently default) ----------------------
|
|
292
|
+
const featureKeys = new Set(Object.keys(config.features));
|
|
293
|
+
if (featureKeys.size === 0) {
|
|
294
|
+
throw new Error("@omg-dev/billing: at least one feature must be declared");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const planKeys = Object.keys(config.plans);
|
|
298
|
+
if (planKeys.length === 0) {
|
|
299
|
+
throw new Error("@omg-dev/billing: at least one plan must be declared");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const defaults = planKeys.filter((k) => config.plans[k].default);
|
|
303
|
+
if (defaults.length !== 1) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`@omg-dev/billing: exactly one plan must be default:true (found ${defaults.length}: [${defaults.join(", ")}])`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// rate cards may only reference declared metered features
|
|
310
|
+
const rateCards = config.usage ?? {};
|
|
311
|
+
for (const fkey of Object.keys(rateCards)) {
|
|
312
|
+
const f = config.features[fkey];
|
|
313
|
+
if (!f) {
|
|
314
|
+
throw new Error(`@omg-dev/billing: rate card references unknown feature "${fkey}"`);
|
|
315
|
+
}
|
|
316
|
+
if (f.kind !== "metered") {
|
|
317
|
+
throw new Error(`@omg-dev/billing: rate card on non-metered feature "${fkey}"`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// grants must reference declared metered features
|
|
322
|
+
const grants = Object.values(config.grants ?? {});
|
|
323
|
+
for (const g of grants) {
|
|
324
|
+
const f = config.features[g.feature];
|
|
325
|
+
if (!f) {
|
|
326
|
+
throw new Error(`@omg-dev/billing: grant references unknown feature "${g.feature}"`);
|
|
327
|
+
}
|
|
328
|
+
if (f.kind !== "metered") {
|
|
329
|
+
throw new Error(`@omg-dev/billing: grant on non-metered feature "${g.feature}"`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// --- normalize -----------------------------------------------------------
|
|
334
|
+
const features: NormalizedFeature[] = Object.entries(config.features).map(
|
|
335
|
+
([key, def]) => ({ key, ...def, label: def.label ?? titleCase(key) }),
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
const plans: NormalizedPlan[] = Object.entries(config.plans).map(([key, p]) => {
|
|
339
|
+
const includes = p.includes ?? {};
|
|
340
|
+
const usage = p.usage ?? {};
|
|
341
|
+
const inclusions: NormalizedPlanInclusion[] = Object.entries(includes).map(
|
|
342
|
+
([feature, inc]) => {
|
|
343
|
+
const f = config.features[feature];
|
|
344
|
+
if (!f) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`@omg-dev/billing: plan "${key}" includes unknown feature "${feature}"`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
if (f.kind === "metered") {
|
|
350
|
+
if (typeof inc === "boolean") {
|
|
351
|
+
throw new Error(
|
|
352
|
+
`@omg-dev/billing: plan "${key}" feature "${feature}" is metered but got a boolean inclusion`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
feature,
|
|
357
|
+
kind: "metered",
|
|
358
|
+
allowance: (inc as Limit).amount,
|
|
359
|
+
window: (inc as Limit).window,
|
|
360
|
+
overage: usage[feature],
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
// boolean feature
|
|
364
|
+
if (typeof inc !== "boolean") {
|
|
365
|
+
throw new Error(
|
|
366
|
+
`@omg-dev/billing: plan "${key}" feature "${feature}" is boolean but got a limit inclusion`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
return { feature, kind: "boolean", enabled: inc };
|
|
370
|
+
},
|
|
371
|
+
);
|
|
372
|
+
return {
|
|
373
|
+
key,
|
|
374
|
+
label: p.label ?? titleCase(key),
|
|
375
|
+
description: p.description,
|
|
376
|
+
price: p.price,
|
|
377
|
+
interval: p.interval,
|
|
378
|
+
default: p.default ?? false,
|
|
379
|
+
inclusions,
|
|
380
|
+
};
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const addOns: NormalizedAddOn[] = Object.entries(config.addOns ?? {}).map(([key, a]) => {
|
|
384
|
+
if (!Number.isInteger(a.price) || a.price < 0) {
|
|
385
|
+
throw new Error(`@omg-dev/billing: add-on "${key}" price must be a non-negative integer`);
|
|
386
|
+
}
|
|
387
|
+
if (a.interval !== "month" && a.interval !== "year") {
|
|
388
|
+
throw new Error(`@omg-dev/billing: add-on "${key}" interval must be month or year`);
|
|
389
|
+
}
|
|
390
|
+
return {
|
|
391
|
+
key,
|
|
392
|
+
label: a.label ?? titleCase(key),
|
|
393
|
+
description: a.description,
|
|
394
|
+
price: a.price,
|
|
395
|
+
interval: a.interval,
|
|
396
|
+
unit: a.unit,
|
|
397
|
+
sizeMultipliers: a.sizeMultipliers,
|
|
398
|
+
metadata: a.metadata,
|
|
399
|
+
};
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
provider: config.provider,
|
|
404
|
+
credit: config.credit,
|
|
405
|
+
features,
|
|
406
|
+
plans,
|
|
407
|
+
addOns,
|
|
408
|
+
rateCards,
|
|
409
|
+
grants,
|
|
410
|
+
config,
|
|
411
|
+
};
|
|
412
|
+
}
|
package/src/react.tsx
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Reusable pricing UI, rendered straight from the Billing declaration so the
|
|
2
|
+
// table can never drift from the code that the reconciler/ledger enforce.
|
|
3
|
+
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import {
|
|
6
|
+
type Billing,
|
|
7
|
+
type Money,
|
|
8
|
+
type NormalizedPlan,
|
|
9
|
+
type NormalizedPlanInclusion,
|
|
10
|
+
formatMoney,
|
|
11
|
+
} from "./index.ts";
|
|
12
|
+
|
|
13
|
+
export interface PricingTableProps {
|
|
14
|
+
billing: Billing;
|
|
15
|
+
/** Currently active plan key — renders "Current plan" instead of a CTA. */
|
|
16
|
+
currentPlan?: string;
|
|
17
|
+
/** CTA handler. Omit to render the table read-only (marketing pages). */
|
|
18
|
+
onSelectPlan?: (planKey: string) => void;
|
|
19
|
+
/** Override CTA label (default: "Choose <Plan>"). */
|
|
20
|
+
ctaLabel?: (plan: NormalizedPlan) => string;
|
|
21
|
+
/**
|
|
22
|
+
* Override the rendered text of a single inclusion line. Return a string to
|
|
23
|
+
* replace the default (e.g. "$3 Build AI"), or null/undefined to keep it.
|
|
24
|
+
* Lets a caller display usage multipliers ("5× Build AI") instead of raw
|
|
25
|
+
* dollar allowances without forking the table.
|
|
26
|
+
*/
|
|
27
|
+
renderInclusion?: (
|
|
28
|
+
inc: NormalizedPlanInclusion,
|
|
29
|
+
ctx: { plan: NormalizedPlan; billing: Billing },
|
|
30
|
+
) => string | null | undefined;
|
|
31
|
+
className?: string;
|
|
32
|
+
/** Order plans by price ascending (default true). */
|
|
33
|
+
sortByPrice?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function intervalSuffix(interval?: "month" | "year"): string {
|
|
37
|
+
if (!interval) return "";
|
|
38
|
+
return interval === "month" ? "/mo" : "/yr";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function priceLabel(price: Money, interval?: "month" | "year"): string {
|
|
42
|
+
if (price === 0) return "Free";
|
|
43
|
+
return `${formatMoney(price)}${intervalSuffix(interval)}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One human-readable line per inclusion, derived from the normalized plan. */
|
|
47
|
+
function inclusionLines(
|
|
48
|
+
plan: NormalizedPlan,
|
|
49
|
+
billing: Billing,
|
|
50
|
+
renderInclusion?: PricingTableProps["renderInclusion"],
|
|
51
|
+
): string[] {
|
|
52
|
+
const labelFor = (key: string) =>
|
|
53
|
+
billing.features.find((f) => f.key === key)?.label ?? key;
|
|
54
|
+
const lines: string[] = [];
|
|
55
|
+
for (const inc of plan.inclusions) {
|
|
56
|
+
const override = renderInclusion?.(inc, { plan, billing });
|
|
57
|
+
if (override != null) {
|
|
58
|
+
lines.push(override);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (inc.kind === "metered") {
|
|
62
|
+
const amount = inc.allowance != null ? formatMoney(inc.allowance) : "—";
|
|
63
|
+
let line = `${amount} ${labelFor(inc.feature)}`;
|
|
64
|
+
if (inc.overage?.allow) line += ", then pay-as-you-go";
|
|
65
|
+
lines.push(line);
|
|
66
|
+
} else if (inc.enabled) {
|
|
67
|
+
lines.push(labelFor(inc.feature));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function PricingTable({
|
|
74
|
+
billing,
|
|
75
|
+
currentPlan,
|
|
76
|
+
onSelectPlan,
|
|
77
|
+
ctaLabel,
|
|
78
|
+
renderInclusion,
|
|
79
|
+
className,
|
|
80
|
+
sortByPrice = true,
|
|
81
|
+
}: PricingTableProps) {
|
|
82
|
+
const plans = React.useMemo(() => {
|
|
83
|
+
const list = [...billing.plans];
|
|
84
|
+
if (sortByPrice) list.sort((a, b) => a.price - b.price);
|
|
85
|
+
return list;
|
|
86
|
+
}, [billing.plans, sortByPrice]);
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div
|
|
90
|
+
className={className}
|
|
91
|
+
style={{
|
|
92
|
+
display: "grid",
|
|
93
|
+
// auto-fit lets the cards stack on narrow screens without a media
|
|
94
|
+
// query (inline styles can't carry one) — 220px min per card.
|
|
95
|
+
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
|
96
|
+
gap: "1rem",
|
|
97
|
+
}}
|
|
98
|
+
data-vibes-pricing-table
|
|
99
|
+
>
|
|
100
|
+
{plans.map((plan) => {
|
|
101
|
+
const isCurrent = currentPlan === plan.key;
|
|
102
|
+
const lines = inclusionLines(plan, billing, renderInclusion);
|
|
103
|
+
return (
|
|
104
|
+
<div
|
|
105
|
+
key={plan.key}
|
|
106
|
+
data-plan={plan.key}
|
|
107
|
+
data-default={plan.default || undefined}
|
|
108
|
+
style={{
|
|
109
|
+
border: "1px solid var(--border, #e5e7eb)",
|
|
110
|
+
borderRadius: "0.75rem",
|
|
111
|
+
padding: "1.25rem",
|
|
112
|
+
display: "flex",
|
|
113
|
+
flexDirection: "column",
|
|
114
|
+
gap: "0.75rem",
|
|
115
|
+
}}
|
|
116
|
+
>
|
|
117
|
+
<div>
|
|
118
|
+
<div style={{ fontWeight: 600, fontSize: "1rem" }}>{plan.label}</div>
|
|
119
|
+
{plan.description ? (
|
|
120
|
+
<div style={{ fontSize: "0.8125rem", opacity: 0.7 }}>
|
|
121
|
+
{plan.description}
|
|
122
|
+
</div>
|
|
123
|
+
) : null}
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<div style={{ fontSize: "1.5rem", fontWeight: 700 }}>
|
|
127
|
+
{priceLabel(plan.price, plan.interval)}
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<ul style={{ listStyle: "none", padding: 0, margin: 0, display: "grid", gap: "0.375rem", flex: 1 }}>
|
|
131
|
+
{lines.map((line, i) => (
|
|
132
|
+
<li key={i} style={{ fontSize: "0.875rem", display: "flex", gap: "0.5rem" }}>
|
|
133
|
+
<span aria-hidden>✓</span>
|
|
134
|
+
<span>{line}</span>
|
|
135
|
+
</li>
|
|
136
|
+
))}
|
|
137
|
+
</ul>
|
|
138
|
+
|
|
139
|
+
{isCurrent ? (
|
|
140
|
+
<div
|
|
141
|
+
style={{
|
|
142
|
+
textAlign: "center",
|
|
143
|
+
fontSize: "0.875rem",
|
|
144
|
+
fontWeight: 600,
|
|
145
|
+
opacity: 0.7,
|
|
146
|
+
padding: "0.5rem",
|
|
147
|
+
}}
|
|
148
|
+
data-current
|
|
149
|
+
>
|
|
150
|
+
Current plan
|
|
151
|
+
</div>
|
|
152
|
+
) : onSelectPlan ? (
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
onClick={() => onSelectPlan(plan.key)}
|
|
156
|
+
style={{
|
|
157
|
+
cursor: "pointer",
|
|
158
|
+
border: "1px solid var(--border, #e5e7eb)",
|
|
159
|
+
borderRadius: "0.5rem",
|
|
160
|
+
padding: "0.5rem 0.75rem",
|
|
161
|
+
fontSize: "0.875rem",
|
|
162
|
+
fontWeight: 600,
|
|
163
|
+
background: plan.default ? "var(--primary, #111)" : "transparent",
|
|
164
|
+
color: plan.default ? "var(--primary-foreground, #fff)" : "inherit",
|
|
165
|
+
}}
|
|
166
|
+
>
|
|
167
|
+
{ctaLabel ? ctaLabel(plan) : `Choose ${plan.label}`}
|
|
168
|
+
</button>
|
|
169
|
+
) : null}
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
})}
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|