@butinapp/sdk 0.1.0
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/LICENSE +21 -0
- package/README.md +63 -0
- package/dist/data/builders.d.ts +111 -0
- package/dist/data/builders.js +103 -0
- package/dist/data/currency.d.ts +3 -0
- package/dist/data/currency.js +11 -0
- package/dist/data/dataset.d.ts +229 -0
- package/dist/data/dataset.js +70 -0
- package/dist/data/index.d.ts +13 -0
- package/dist/data/index.js +11 -0
- package/dist/data/result.d.ts +163 -0
- package/dist/data/result.js +171 -0
- package/dist/data/roles.d.ts +17 -0
- package/dist/data/roles.js +3 -0
- package/dist/data/series.d.ts +6 -0
- package/dist/data/series.js +5 -0
- package/dist/data/summary.d.ts +36 -0
- package/dist/data/summary.js +18 -0
- package/dist/data/view.d.ts +93 -0
- package/dist/data/view.js +74 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +14 -0
- package/dist/integrations/index.d.ts +1 -0
- package/dist/integrations/index.js +4 -0
- package/dist/integrations/stripe.d.ts +45 -0
- package/dist/integrations/stripe.js +102 -0
- package/dist/libs.d.ts +2 -0
- package/dist/libs.js +6 -0
- package/dist/plugin/auth.d.ts +49 -0
- package/dist/plugin/auth.js +1 -0
- package/dist/plugin/browser.d.ts +26 -0
- package/dist/plugin/browser.js +8 -0
- package/dist/plugin/capability.d.ts +54 -0
- package/dist/plugin/capability.js +19 -0
- package/dist/plugin/config.d.ts +61 -0
- package/dist/plugin/config.js +22 -0
- package/dist/plugin/documents.d.ts +5 -0
- package/dist/plugin/documents.js +1 -0
- package/dist/plugin/meta.d.ts +21 -0
- package/dist/plugin/meta.js +1 -0
- package/dist/plugin/plugin.d.ts +26 -0
- package/dist/plugin/plugin.js +1 -0
- package/dist/plugin/session.d.ts +33 -0
- package/dist/plugin/session.js +5 -0
- package/dist/plugin/transport.d.ts +42 -0
- package/dist/plugin/transport.js +1 -0
- package/dist/presets/apikeys.d.ts +13 -0
- package/dist/presets/apikeys.js +24 -0
- package/dist/presets/billing.d.ts +56 -0
- package/dist/presets/billing.js +209 -0
- package/dist/presets/blocks.d.ts +43 -0
- package/dist/presets/blocks.js +99 -0
- package/dist/presets/index.d.ts +38 -0
- package/dist/presets/index.js +22 -0
- package/dist/presets/members.d.ts +11 -0
- package/dist/presets/members.js +15 -0
- package/dist/presets/mtd-basis.d.ts +11 -0
- package/dist/presets/mtd-basis.js +16 -0
- package/dist/presets/usage.d.ts +17 -0
- package/dist/presets/usage.js +61 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +6 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +6 -0
- package/dist/testing/synthetic.d.ts +65 -0
- package/dist/testing/synthetic.js +259 -0
- package/dist/util/date.d.ts +14 -0
- package/dist/util/date.js +49 -0
- package/dist/util/fx.d.ts +2 -0
- package/dist/util/fx.js +7 -0
- package/dist/util/index.d.ts +6 -0
- package/dist/util/index.js +9 -0
- package/dist/util/money.d.ts +6 -0
- package/dist/util/money.js +35 -0
- package/dist/util/object.d.ts +2 -0
- package/dist/util/object.js +7 -0
- package/dist/util/text.d.ts +2 -0
- package/dist/util/text.js +16 -0
- package/package.json +70 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { capabilityResult, record, table } from '../data/builders.js';
|
|
2
|
+
import { resolveTableFiles } from '../data/view.js';
|
|
3
|
+
import { round2 } from '../util/money.js';
|
|
4
|
+
import { creditsRecord, paymentMethodRecord } from './blocks.js';
|
|
5
|
+
import { isAccrualBasis } from './mtd-basis.js';
|
|
6
|
+
// The fileTable spec for the default `invoiceDownload: true`: the host GETs each row's pdfUrl and saves it
|
|
7
|
+
// named from the row's `name` column. A plugin can pass an explicit TableFiles descriptor to override.
|
|
8
|
+
const DEFAULT_INVOICE_FILES = {
|
|
9
|
+
title: 'Invoices',
|
|
10
|
+
name: 'name',
|
|
11
|
+
source: { url: 'pdfUrl' },
|
|
12
|
+
ext: 'pdf',
|
|
13
|
+
category: 'Invoices'
|
|
14
|
+
};
|
|
15
|
+
// Bucket invoice amounts by calendar month (ascending) → the monthly-spend series both the Summary chart
|
|
16
|
+
// and the cross-service Overview spark read. Rounded once per month to avoid IEEE-754 drift (0.1+0.2≠0.3).
|
|
17
|
+
// Undated invoices can't bucket onto the monthly axis, so they're skipped.
|
|
18
|
+
export const monthlySpend = (invoices) => {
|
|
19
|
+
const byMonth = new Map();
|
|
20
|
+
for (const inv of invoices) {
|
|
21
|
+
if (!inv.date) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const month = inv.date.slice(0, 7);
|
|
25
|
+
byMonth.set(month, (byMonth.get(month) ?? 0) + inv.amount);
|
|
26
|
+
}
|
|
27
|
+
return [...byMonth.entries()]
|
|
28
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
29
|
+
.map(([month, amount]) => ({ month, amount: round2(amount) }));
|
|
30
|
+
};
|
|
31
|
+
// The change from the previous period, in the same money unit (positive = spending more). The baseline depends
|
|
32
|
+
// on what currentMtd measures: an invoiced/last-invoice figure IS the latest month in the series, so it's
|
|
33
|
+
// compared against the prior month; a live figure (accrued/upcoming/flat) is compared against the last full
|
|
34
|
+
// month (the latest series entry). undefined when there's no currentMtd or no baseline month to compare against.
|
|
35
|
+
const monthOverMonthDelta = (months, currentMtd, basis) => {
|
|
36
|
+
if (currentMtd == null) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const baseline = isAccrualBasis(basis) ? months.at(-1) : months.at(-2);
|
|
40
|
+
return baseline ? round2(currentMtd - baseline.amount) : undefined;
|
|
41
|
+
};
|
|
42
|
+
const monthlyTable = (months, currency) => table({
|
|
43
|
+
id: 'monthly',
|
|
44
|
+
columns: [
|
|
45
|
+
{ key: 'month', label: 'Month', role: 'timestamp' },
|
|
46
|
+
{ key: 'amount', label: 'Spend', role: 'money', currency }
|
|
47
|
+
],
|
|
48
|
+
rows: months,
|
|
49
|
+
key: 'month'
|
|
50
|
+
});
|
|
51
|
+
// The Summary preset: a headline `account` stat (currentMtd + optional plan + caller
|
|
52
|
+
// stats) + the monthly-spend chart (the Overview spark) + the spend.mtd summary. Deliberately carries NO
|
|
53
|
+
// invoices table — that's the thinner detail ('invoicing') tab. So a billing plugin's Summary collect() is
|
|
54
|
+
// `return billingSummaryResult({...})`, and its detail tab builds the invoices table (+ any extras).
|
|
55
|
+
export const billingSummaryResult = (input) => {
|
|
56
|
+
const label = input.currentMtdLabel ?? 'This month';
|
|
57
|
+
const fields = [{ key: 'currentMtd', label, role: 'money', currency: input.currency }];
|
|
58
|
+
const value = { currentMtd: input.currentMtd };
|
|
59
|
+
// Parallel to `fields`: each field's stat-card presentation (denominator/unit/caption/tone). A bare key
|
|
60
|
+
// renders plain; a spec adds the rich layers. Order tracks `fields` so cards render in the declared order.
|
|
61
|
+
const statFields = [
|
|
62
|
+
input.currentMtdCaption ? { key: 'currentMtd', caption: input.currentMtdCaption } : 'currentMtd'
|
|
63
|
+
];
|
|
64
|
+
if (input.baseFee !== undefined) {
|
|
65
|
+
fields.push({ key: 'baseFee', label: 'Base', role: 'money', currency: input.currency });
|
|
66
|
+
value.baseFee = input.baseFee;
|
|
67
|
+
statFields.push('baseFee');
|
|
68
|
+
}
|
|
69
|
+
if (input.meteredMtd !== undefined) {
|
|
70
|
+
fields.push({ key: 'meteredMtd', label: 'Overage', role: 'money', currency: input.currency });
|
|
71
|
+
value.meteredMtd = input.meteredMtd;
|
|
72
|
+
statFields.push('meteredMtd');
|
|
73
|
+
}
|
|
74
|
+
if (input.plan !== undefined) {
|
|
75
|
+
fields.push({ key: 'plan', label: 'Plan', role: 'label' });
|
|
76
|
+
value.plan = input.plan ?? null;
|
|
77
|
+
statFields.push('plan');
|
|
78
|
+
}
|
|
79
|
+
const months = monthlySpend(input.invoices);
|
|
80
|
+
const delta = input.showDelta === false ? undefined : monthOverMonthDelta(months, input.currentMtd, input.mtdBasis);
|
|
81
|
+
if (delta !== undefined) {
|
|
82
|
+
fields.push({ key: 'momDelta', label: 'Δ vs last month', role: 'money', currency: input.currency });
|
|
83
|
+
value.momDelta = delta;
|
|
84
|
+
// Spending less than the baseline is the good direction → green; more → red.
|
|
85
|
+
statFields.push({ key: 'momDelta', tone: delta < 0 ? 'positive' : delta > 0 ? 'negative' : 'muted' });
|
|
86
|
+
}
|
|
87
|
+
for (const stat of input.stats ?? []) {
|
|
88
|
+
fields.push({ key: stat.key, label: stat.label, role: stat.role, currency: stat.currency, badges: stat.badges });
|
|
89
|
+
value[stat.key] = stat.value;
|
|
90
|
+
statFields.push(stat.max != null || stat.unit != null || stat.caption != null || stat.tone != null
|
|
91
|
+
? { key: stat.key, max: stat.max, unit: stat.unit, caption: stat.caption, tone: stat.tone }
|
|
92
|
+
: stat.key);
|
|
93
|
+
}
|
|
94
|
+
const account = record.fromColumns({ id: 'account', fields, value });
|
|
95
|
+
const monthly = monthlyTable(months, input.currency);
|
|
96
|
+
const s = input.currentMtd != null
|
|
97
|
+
? monthly.summary({
|
|
98
|
+
section: 'spend',
|
|
99
|
+
label,
|
|
100
|
+
value: input.currentMtd,
|
|
101
|
+
currency: input.currency,
|
|
102
|
+
basis: input.mtdBasis ?? 'invoiced',
|
|
103
|
+
x: 'month',
|
|
104
|
+
y: 'amount'
|
|
105
|
+
})
|
|
106
|
+
: undefined;
|
|
107
|
+
return capabilityResult({
|
|
108
|
+
sections: [
|
|
109
|
+
account.stat({ fields: statFields }),
|
|
110
|
+
monthly.timeseries({
|
|
111
|
+
x: 'month',
|
|
112
|
+
y: 'amount',
|
|
113
|
+
granularity: 'monthly',
|
|
114
|
+
title: input.monthlyTitle ?? 'Monthly spend'
|
|
115
|
+
})
|
|
116
|
+
],
|
|
117
|
+
summaries: s ? [s] : undefined
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
// The billing preset: build the role-tagged datasets, the default views, and the spend.mtd summary,
|
|
121
|
+
// so a single-tab billing plugin's whole collect() is `return billingResult({...})`. (Plugins that split
|
|
122
|
+
// into Summary + detail tabs use billingSummaryResult + their own invoices table instead.)
|
|
123
|
+
export const billingResult = (input) => {
|
|
124
|
+
const invoices = table({
|
|
125
|
+
id: 'invoices',
|
|
126
|
+
columns: [
|
|
127
|
+
{ key: 'date', label: 'Date', role: 'timestamp' },
|
|
128
|
+
{ key: 'amount', label: 'Amount', role: 'money', currency: input.currency },
|
|
129
|
+
{ key: 'status', label: 'Status', role: 'status', badges: input.statusTones },
|
|
130
|
+
{ key: 'pdfUrl', label: 'PDF', role: 'url' },
|
|
131
|
+
{ key: 'id', role: 'identifier', hidden: true },
|
|
132
|
+
{ key: 'name', role: 'label', hidden: true }
|
|
133
|
+
],
|
|
134
|
+
rows: input.invoices.map((i) => ({
|
|
135
|
+
// `id` (provider id, else a date+amount fallback) keys the dataset so invoices accumulate past the window.
|
|
136
|
+
id: i.id ?? `${i.date ?? 'undated'}:${i.amount}`,
|
|
137
|
+
date: i.date ?? null,
|
|
138
|
+
amount: i.amount,
|
|
139
|
+
status: i.status,
|
|
140
|
+
pdfUrl: i.pdfUrl ?? i.hostedUrl ?? null,
|
|
141
|
+
// The download host names each file from this column; absent it falls back to the row index.
|
|
142
|
+
name: `Invoice ${i.date ?? 'unknown'}`
|
|
143
|
+
})),
|
|
144
|
+
key: 'id'
|
|
145
|
+
});
|
|
146
|
+
const invoicesSection = input.invoiceDownload === true
|
|
147
|
+
? invoices.fileTable(DEFAULT_INVOICE_FILES)
|
|
148
|
+
: input.invoiceDownload
|
|
149
|
+
? invoices.fileTable(fileTableFromOverride(input.invoiceDownload))
|
|
150
|
+
: invoices.table({ title: 'Invoices' });
|
|
151
|
+
const accountFields = [{ key: 'currentMtd', label: 'This month', role: 'money', currency: input.currency }];
|
|
152
|
+
const accountValue = { currentMtd: input.currentMtd };
|
|
153
|
+
if (input.baseFee !== undefined) {
|
|
154
|
+
accountFields.push({ key: 'baseFee', label: 'Base', role: 'money', currency: input.currency });
|
|
155
|
+
accountValue.baseFee = input.baseFee;
|
|
156
|
+
}
|
|
157
|
+
if (input.meteredMtd !== undefined) {
|
|
158
|
+
accountFields.push({ key: 'meteredMtd', label: 'Overage', role: 'money', currency: input.currency });
|
|
159
|
+
accountValue.meteredMtd = input.meteredMtd;
|
|
160
|
+
}
|
|
161
|
+
const months = monthlySpend(input.invoices);
|
|
162
|
+
const delta = monthOverMonthDelta(months, input.currentMtd, input.mtdBasis);
|
|
163
|
+
if (delta !== undefined) {
|
|
164
|
+
accountFields.push({ key: 'momDelta', label: 'Δ vs last month', role: 'money', currency: input.currency });
|
|
165
|
+
accountValue.momDelta = delta;
|
|
166
|
+
}
|
|
167
|
+
accountFields.push({ key: 'plan', label: 'Plan', role: 'label' });
|
|
168
|
+
accountValue.plan = input.plan ?? null;
|
|
169
|
+
const account = record.fromColumns({ id: 'account', fields: accountFields, value: accountValue });
|
|
170
|
+
const monthly = monthlyTable(months, input.currency);
|
|
171
|
+
const pm = input.paymentMethod ? paymentMethodRecord(input.paymentMethod) : undefined;
|
|
172
|
+
const credits = input.credits
|
|
173
|
+
? creditsRecord({ ...input.credits, currency: input.credits.currency ?? input.currency })
|
|
174
|
+
: undefined;
|
|
175
|
+
const s = input.currentMtd != null
|
|
176
|
+
? monthly.summary({
|
|
177
|
+
section: 'spend',
|
|
178
|
+
label: 'This month',
|
|
179
|
+
value: input.currentMtd,
|
|
180
|
+
currency: input.currency,
|
|
181
|
+
basis: input.mtdBasis ?? 'invoiced',
|
|
182
|
+
x: 'month',
|
|
183
|
+
y: 'amount'
|
|
184
|
+
})
|
|
185
|
+
: undefined;
|
|
186
|
+
return capabilityResult({
|
|
187
|
+
sections: [
|
|
188
|
+
account.stat(),
|
|
189
|
+
pm,
|
|
190
|
+
credits,
|
|
191
|
+
monthly.timeseries({ x: 'month', y: 'amount', granularity: 'monthly', title: 'Monthly spend' }),
|
|
192
|
+
invoicesSection
|
|
193
|
+
],
|
|
194
|
+
summaries: s ? [s] : undefined
|
|
195
|
+
});
|
|
196
|
+
};
|
|
197
|
+
// Normalize an explicit TableFiles override (`source`/`name`/`folder`) into a fileTable spec, defaulting the
|
|
198
|
+
// same wiring as `invoiceDownload: true`.
|
|
199
|
+
const fileTableFromOverride = (override) => {
|
|
200
|
+
const { urlKey, nameKey, folderKey, ext, category, useFetch } = resolveTableFiles(override);
|
|
201
|
+
return {
|
|
202
|
+
title: 'Invoices',
|
|
203
|
+
name: (nameKey ?? 'name'),
|
|
204
|
+
source: useFetch ? { fetch: true } : { url: (urlKey ?? 'pdfUrl') },
|
|
205
|
+
ext,
|
|
206
|
+
category: category ?? 'Invoices',
|
|
207
|
+
folder: folderKey
|
|
208
|
+
};
|
|
209
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type ViewSpec } from '../data/builders.js';
|
|
2
|
+
export declare const overageOf: (total: number | null | undefined, base: number | null | undefined) => number | null;
|
|
3
|
+
export interface TrendPoint {
|
|
4
|
+
date: string;
|
|
5
|
+
cost?: number;
|
|
6
|
+
value?: number;
|
|
7
|
+
}
|
|
8
|
+
export declare const dailySeries: (id: string, rows: TrendPoint[], opts?: {
|
|
9
|
+
title?: string;
|
|
10
|
+
currency?: string;
|
|
11
|
+
valueLabel?: string;
|
|
12
|
+
}) => ViewSpec;
|
|
13
|
+
export interface CreditsInput {
|
|
14
|
+
granted?: number;
|
|
15
|
+
balance?: number;
|
|
16
|
+
used?: number;
|
|
17
|
+
netSpend?: number;
|
|
18
|
+
currency?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare const creditsRecord: (input: CreditsInput, opts?: {
|
|
21
|
+
id?: string;
|
|
22
|
+
title?: string;
|
|
23
|
+
}) => ViewSpec;
|
|
24
|
+
export interface SubscriptionInput {
|
|
25
|
+
plan?: string;
|
|
26
|
+
status?: string;
|
|
27
|
+
seats?: number;
|
|
28
|
+
unitPrice?: number;
|
|
29
|
+
periodStart?: string;
|
|
30
|
+
periodEnd?: string;
|
|
31
|
+
currency?: string;
|
|
32
|
+
title?: string;
|
|
33
|
+
}
|
|
34
|
+
export declare const subscriptionRecord: (input: SubscriptionInput) => ViewSpec;
|
|
35
|
+
export interface PaymentMethodInput {
|
|
36
|
+
brand: string;
|
|
37
|
+
last4: string;
|
|
38
|
+
title?: string;
|
|
39
|
+
}
|
|
40
|
+
export declare const paymentMethodRecord: (input: PaymentMethodInput, opts?: {
|
|
41
|
+
id?: string;
|
|
42
|
+
title?: string;
|
|
43
|
+
}) => ViewSpec;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { record, table } from '../data/builders.js';
|
|
2
|
+
import { round2 } from '../util/money.js';
|
|
3
|
+
// Composable building blocks for collect() results. Each returns a `ViewSpec` a plugin mixes into any
|
|
4
|
+
// CapabilityResult — the lego layer under the opinionated presets (billingResult/usageResult), for rich
|
|
5
|
+
// tabs that outgrow a single preset call. Pure data; fixture-tested in blocks.test.ts.
|
|
6
|
+
// The metered overage above a plan's base fee = max(0, total − base), rounded to cents. null when either side
|
|
7
|
+
// is unknown, so the Overage cell shows an em-dash rather than a misleading 0. Negative deltas (a base fee not
|
|
8
|
+
// yet invoiced this period) clamp to 0, never a negative overage.
|
|
9
|
+
export const overageOf = (total, base) => total != null && base != null ? round2(Math.max(0, total - base)) : null;
|
|
10
|
+
export const dailySeries = (id, rows, opts = {}) => {
|
|
11
|
+
const isCost = rows.some((r) => r.cost != null);
|
|
12
|
+
const yLabel = opts.valueLabel ?? (isCost ? 'Cost' : 'Usage');
|
|
13
|
+
// Keyed on the day so each date accumulates as its own row — the full daily history outlives a rolling window.
|
|
14
|
+
if (isCost) {
|
|
15
|
+
const t = table({
|
|
16
|
+
id,
|
|
17
|
+
columns: [
|
|
18
|
+
{ key: 'date', label: 'Date', role: 'timestamp' },
|
|
19
|
+
{ key: 'cost', label: yLabel, role: 'money', currency: opts.currency }
|
|
20
|
+
],
|
|
21
|
+
rows: rows.map((r) => ({ date: r.date, cost: r.cost ?? 0 })),
|
|
22
|
+
key: 'date'
|
|
23
|
+
});
|
|
24
|
+
return t.timeseries({ x: 'date', y: 'cost', granularity: 'daily', title: opts.title ?? 'Daily cost' });
|
|
25
|
+
}
|
|
26
|
+
const t = table({
|
|
27
|
+
id,
|
|
28
|
+
columns: [
|
|
29
|
+
{ key: 'date', label: 'Date', role: 'timestamp' },
|
|
30
|
+
{ key: 'value', label: yLabel, role: 'count' }
|
|
31
|
+
],
|
|
32
|
+
rows: rows.map((r) => ({ date: r.date, value: r.value ?? 0 })),
|
|
33
|
+
key: 'date'
|
|
34
|
+
});
|
|
35
|
+
return t.timeseries({ x: 'date', y: 'value', granularity: 'daily', title: opts.title ?? 'Daily usage' });
|
|
36
|
+
};
|
|
37
|
+
const CREDIT_FIELDS = [
|
|
38
|
+
{ key: 'netSpend', label: 'Net spend' },
|
|
39
|
+
{ key: 'used', label: 'Credits used' },
|
|
40
|
+
{ key: 'balance', label: 'Credit balance' },
|
|
41
|
+
{ key: 'granted', label: 'Credit granted' }
|
|
42
|
+
];
|
|
43
|
+
export const creditsRecord = (input, opts = {}) => {
|
|
44
|
+
const id = opts.id ?? 'credits';
|
|
45
|
+
const fields = [];
|
|
46
|
+
const value = {};
|
|
47
|
+
for (const f of CREDIT_FIELDS) {
|
|
48
|
+
if (input[f.key] != null) {
|
|
49
|
+
fields.push({ key: f.key, label: f.label, role: 'money', currency: input.currency });
|
|
50
|
+
value[f.key] = input[f.key];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return record.fromColumns({ id, fields, value }).keyvalue({
|
|
54
|
+
title: opts.title ?? 'Credits & spend'
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
export const subscriptionRecord = (input) => {
|
|
58
|
+
const fields = [];
|
|
59
|
+
const value = {};
|
|
60
|
+
if (input.plan != null) {
|
|
61
|
+
fields.push({ key: 'plan', label: 'Plan', role: 'label' });
|
|
62
|
+
value.plan = input.plan;
|
|
63
|
+
}
|
|
64
|
+
if (input.status != null) {
|
|
65
|
+
fields.push({ key: 'status', label: 'Status', role: 'label' });
|
|
66
|
+
value.status = input.status;
|
|
67
|
+
}
|
|
68
|
+
if (input.seats != null) {
|
|
69
|
+
fields.push({ key: 'seats', label: 'Seats', role: 'count' });
|
|
70
|
+
value.seats = input.seats;
|
|
71
|
+
}
|
|
72
|
+
if (input.unitPrice != null) {
|
|
73
|
+
fields.push({ key: 'unitPrice', label: 'Unit price', role: 'money', currency: input.currency });
|
|
74
|
+
value.unitPrice = input.unitPrice;
|
|
75
|
+
}
|
|
76
|
+
const period = [input.periodStart, input.periodEnd].filter(Boolean).join(' → ');
|
|
77
|
+
if (period) {
|
|
78
|
+
fields.push({ key: 'period', label: 'Period', role: 'text' });
|
|
79
|
+
value.period = period;
|
|
80
|
+
}
|
|
81
|
+
return record.fromColumns({ id: 'subscription', fields, value }).keyvalue({
|
|
82
|
+
title: input.title ?? 'Subscription'
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
export const paymentMethodRecord = (input, opts = {}) => {
|
|
86
|
+
const id = opts.id ?? 'paymentMethod';
|
|
87
|
+
const fields = [
|
|
88
|
+
{ key: 'brand', label: 'Card', role: 'label' },
|
|
89
|
+
{ key: 'last4', label: 'Last 4', role: 'label' }
|
|
90
|
+
];
|
|
91
|
+
const value = { brand: input.brand, last4: input.last4 };
|
|
92
|
+
if (input.title) {
|
|
93
|
+
fields.push({ key: 'title', label: 'Title', role: 'label' });
|
|
94
|
+
value.title = input.title;
|
|
95
|
+
}
|
|
96
|
+
return record.fromColumns({ id, fields, value }).keyvalue({
|
|
97
|
+
title: opts.title ?? 'Payment method'
|
|
98
|
+
});
|
|
99
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export declare const billing: {
|
|
2
|
+
result: (input: import("./billing.js").BillingInput) => import("../data/result.js").CapabilityResult;
|
|
3
|
+
summary: (input: import("./billing.js").BillingSummaryInput) => import("../data/result.js").CapabilityResult;
|
|
4
|
+
monthlySpend: (invoices: import("./billing.js").BillingInvoiceInput[]) => import("../data/series.js").MonthPoint[];
|
|
5
|
+
};
|
|
6
|
+
export declare const usage: {
|
|
7
|
+
result: (input: import("./usage.js").UsageInput) => import("../data/result.js").CapabilityResult;
|
|
8
|
+
};
|
|
9
|
+
export declare const keys: {
|
|
10
|
+
result: (input: import("./apikeys.js").ApiKeysInput) => import("../data/result.js").CapabilityResult;
|
|
11
|
+
};
|
|
12
|
+
export declare const members: {
|
|
13
|
+
result: (input: import("./members.js").MembersInput) => import("../data/result.js").CapabilityResult;
|
|
14
|
+
};
|
|
15
|
+
export declare const blocks: {
|
|
16
|
+
credits: (input: import("./blocks.js").CreditsInput, opts?: {
|
|
17
|
+
id?: string;
|
|
18
|
+
title?: string;
|
|
19
|
+
}) => import("../data/builders.js").ViewSpec;
|
|
20
|
+
paymentMethod: (input: import("./blocks.js").PaymentMethodInput, opts?: {
|
|
21
|
+
id?: string;
|
|
22
|
+
title?: string;
|
|
23
|
+
}) => import("../data/builders.js").ViewSpec;
|
|
24
|
+
subscription: (input: import("./blocks.js").SubscriptionInput) => import("../data/builders.js").ViewSpec;
|
|
25
|
+
daily: (id: string, rows: import("./blocks.js").TrendPoint[], opts?: {
|
|
26
|
+
title?: string;
|
|
27
|
+
currency?: string;
|
|
28
|
+
valueLabel?: string;
|
|
29
|
+
}) => import("../data/builders.js").ViewSpec;
|
|
30
|
+
overageOf: (total: number | null | undefined, base: number | null | undefined) => number | null;
|
|
31
|
+
};
|
|
32
|
+
export type { BillingInput, BillingInvoiceInput, BillingStat, BillingSummaryInput } from './billing.js';
|
|
33
|
+
export type { UsageInput, UsageMetricInput } from './usage.js';
|
|
34
|
+
export type { ApiKeyInput, ApiKeysInput } from './apikeys.js';
|
|
35
|
+
export type { MemberInput, MembersInput } from './members.js';
|
|
36
|
+
export type { CreditsInput, PaymentMethodInput, SubscriptionInput, TrendPoint } from './blocks.js';
|
|
37
|
+
export type { MtdBasis } from './mtd-basis.js';
|
|
38
|
+
export { MtdBasisSchema, MTD_BASIS_LABELS, isAccrualBasis } from './mtd-basis.js';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// @butinapp/sdk/presets — the high-altitude result builders, grouped by domain so a dotted prefix says the
|
|
2
|
+
// tier: `billing.summary(...)` / `billing.result(...)`, `usage.result(...)`, `keys.result(...)`,
|
|
3
|
+
// `members.result(...)`. `blocks.*` are the reusable sub-panels a result composes. Badge coloring is the
|
|
4
|
+
// renderer's job — a status column auto-tones, a category column auto-colors — so there's no tone palette
|
|
5
|
+
// here. The pure raw→CapabilityResult normalizers underneath stay fixture-tested in their own modules.
|
|
6
|
+
import { apiKeysResult } from './apikeys.js';
|
|
7
|
+
import { billingResult, billingSummaryResult, monthlySpend } from './billing.js';
|
|
8
|
+
import { creditsRecord, dailySeries, overageOf, paymentMethodRecord, subscriptionRecord } from './blocks.js';
|
|
9
|
+
import { membersResult } from './members.js';
|
|
10
|
+
import { usageResult } from './usage.js';
|
|
11
|
+
export const billing = { result: billingResult, summary: billingSummaryResult, monthlySpend };
|
|
12
|
+
export const usage = { result: usageResult };
|
|
13
|
+
export const keys = { result: apiKeysResult };
|
|
14
|
+
export const members = { result: membersResult };
|
|
15
|
+
export const blocks = {
|
|
16
|
+
credits: creditsRecord,
|
|
17
|
+
paymentMethod: paymentMethodRecord,
|
|
18
|
+
subscription: subscriptionRecord,
|
|
19
|
+
daily: dailySeries,
|
|
20
|
+
overageOf
|
|
21
|
+
};
|
|
22
|
+
export { MtdBasisSchema, MTD_BASIS_LABELS, isAccrualBasis } from './mtd-basis.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CapabilityResult } from '../data/result.js';
|
|
2
|
+
export interface MemberInput {
|
|
3
|
+
id: string;
|
|
4
|
+
name?: string;
|
|
5
|
+
email?: string;
|
|
6
|
+
role?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface MembersInput {
|
|
9
|
+
members: MemberInput[];
|
|
10
|
+
}
|
|
11
|
+
export declare const membersResult: (input: MembersInput) => CapabilityResult;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { capabilityResult, table } from '../data/builders.js';
|
|
2
|
+
// The members preset: who has access. One table (name / email / role). No summary.
|
|
3
|
+
export const membersResult = (input) => {
|
|
4
|
+
const members = table({
|
|
5
|
+
id: 'members',
|
|
6
|
+
columns: [
|
|
7
|
+
{ key: 'name', role: 'label', label: 'Name' },
|
|
8
|
+
{ key: 'email', role: 'identifier', label: 'Email' },
|
|
9
|
+
{ key: 'role', role: 'category', label: 'Role' }
|
|
10
|
+
],
|
|
11
|
+
rows: input.members.map((m) => ({ id: m.id, name: m.name ?? null, email: m.email ?? null, role: m.role ?? null })),
|
|
12
|
+
key: 'id'
|
|
13
|
+
});
|
|
14
|
+
return capabilityResult({ sections: [members.table({ title: 'Members' })] });
|
|
15
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const MtdBasisSchema: z.ZodEnum<{
|
|
3
|
+
accrued: "accrued";
|
|
4
|
+
flat: "flat";
|
|
5
|
+
invoiced: "invoiced";
|
|
6
|
+
lastInvoice: "lastInvoice";
|
|
7
|
+
upcoming: "upcoming";
|
|
8
|
+
}>;
|
|
9
|
+
export type MtdBasis = z.infer<typeof MtdBasisSchema>;
|
|
10
|
+
export declare const MTD_BASIS_LABELS: Record<MtdBasis, string>;
|
|
11
|
+
export declare const isAccrualBasis: (basis: MtdBasis | undefined) => boolean;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
// What a `spend.mtd` figure measures — so the Overview groups/labels like with like instead of blind-summing
|
|
3
|
+
// a flat fee, an accruing usage total, and a last-month invoice into one meaningless number.
|
|
4
|
+
export const MtdBasisSchema = z.enum(['accrued', 'invoiced', 'flat', 'upcoming', 'lastInvoice']);
|
|
5
|
+
export const MTD_BASIS_LABELS = {
|
|
6
|
+
accrued: 'accrued so far',
|
|
7
|
+
invoiced: 'invoiced this month',
|
|
8
|
+
flat: 'fixed monthly fee',
|
|
9
|
+
upcoming: 'upcoming invoice',
|
|
10
|
+
lastInvoice: 'last invoice'
|
|
11
|
+
};
|
|
12
|
+
// A basis whose current-month figure is a LIVE open-period accrual that grows through the month (or a fixed
|
|
13
|
+
// recurring fee), as opposed to a settled figure keyed to a past invoice. Only an accrual basis may seed the
|
|
14
|
+
// current-month bar: its captured peak is a real estimate of the month's spend, whereas an 'invoiced'/'lastInvoice'
|
|
15
|
+
// value is a prior bill that would synthesize a phantom bar if projected onto a month with no invoice yet.
|
|
16
|
+
export const isAccrualBasis = (basis) => basis === 'accrued' || basis === 'upcoming' || basis === 'flat';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CapabilityResult } from '../data/result.js';
|
|
2
|
+
import { type TrendPoint } from './blocks.js';
|
|
3
|
+
export interface UsageMetricInput {
|
|
4
|
+
label: string;
|
|
5
|
+
value: number;
|
|
6
|
+
unit?: string;
|
|
7
|
+
limit?: number | null;
|
|
8
|
+
cost?: number | null;
|
|
9
|
+
}
|
|
10
|
+
export interface UsageInput {
|
|
11
|
+
periodStart?: string;
|
|
12
|
+
periodEnd?: string;
|
|
13
|
+
metrics: UsageMetricInput[];
|
|
14
|
+
daily?: TrendPoint[];
|
|
15
|
+
dailyTitle?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare const usageResult: (input: UsageInput) => CapabilityResult;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { capabilityResult, record, table } from '../data/builders.js';
|
|
2
|
+
import { round2 } from '../util/money.js';
|
|
3
|
+
import { dailySeries } from './blocks.js';
|
|
4
|
+
// The usage preset: a per-metric table + an 'other'-section money summary when any metric carries a cost (shown
|
|
5
|
+
// per-service on the Overview, never summed into the spend total). A headline record (on-demand spend, period)
|
|
6
|
+
// precedes the table only when there's a spend or period to show — a bare metric COUNT would just echo the
|
|
7
|
+
// table's own row count, so it's never surfaced.
|
|
8
|
+
export const usageResult = (input) => {
|
|
9
|
+
const totalCost = input.metrics.reduce((sum, m) => sum + (m.cost ?? 0), 0);
|
|
10
|
+
const hasCost = input.metrics.some((m) => m.cost != null);
|
|
11
|
+
const period = [input.periodStart, input.periodEnd].filter(Boolean).join(' → ');
|
|
12
|
+
const headFields = [];
|
|
13
|
+
const headValue = {};
|
|
14
|
+
if (hasCost) {
|
|
15
|
+
headFields.push({ key: 'spend', role: 'money', label: 'On-demand spend' });
|
|
16
|
+
headValue.spend = round2(totalCost);
|
|
17
|
+
}
|
|
18
|
+
if (period) {
|
|
19
|
+
headFields.push({ key: 'period', role: 'text', label: 'Period' });
|
|
20
|
+
headValue.period = period;
|
|
21
|
+
}
|
|
22
|
+
const usage = headFields.length
|
|
23
|
+
? record.fromColumns({ id: 'usage', fields: headFields, value: headValue }).stat()
|
|
24
|
+
: undefined;
|
|
25
|
+
const metrics = table({
|
|
26
|
+
id: 'metrics',
|
|
27
|
+
columns: [
|
|
28
|
+
{ key: 'label', role: 'label', label: 'Metric' },
|
|
29
|
+
{ key: 'value', role: 'count', label: 'Used' },
|
|
30
|
+
{ key: 'unit', role: 'label', label: 'Unit' },
|
|
31
|
+
{ key: 'limit', role: 'count', label: 'Limit' },
|
|
32
|
+
{ key: 'cost', role: 'money', label: 'Cost' }
|
|
33
|
+
],
|
|
34
|
+
rows: input.metrics.map((m) => ({
|
|
35
|
+
label: m.label,
|
|
36
|
+
value: m.value,
|
|
37
|
+
unit: m.unit ?? null,
|
|
38
|
+
limit: m.limit ?? null,
|
|
39
|
+
cost: m.cost ?? null
|
|
40
|
+
}))
|
|
41
|
+
});
|
|
42
|
+
// The daily trend renders between the stat and the per-metric table (stat → chart → table).
|
|
43
|
+
const daily = input.daily?.length
|
|
44
|
+
? dailySeries('daily', input.daily, { title: input.dailyTitle ?? 'Daily cost' })
|
|
45
|
+
: undefined;
|
|
46
|
+
const summary = hasCost
|
|
47
|
+
? {
|
|
48
|
+
section: 'other',
|
|
49
|
+
label: 'On-demand spend',
|
|
50
|
+
value: round2(totalCost),
|
|
51
|
+
role: 'money',
|
|
52
|
+
...(input.daily?.length
|
|
53
|
+
? { spark: { dataset: 'daily', x: 'date', y: input.daily.some((d) => d.cost != null) ? 'cost' : 'value' } }
|
|
54
|
+
: {})
|
|
55
|
+
}
|
|
56
|
+
: undefined;
|
|
57
|
+
return capabilityResult({
|
|
58
|
+
sections: [usage, daily, metrics.table({ title: 'Usage' })],
|
|
59
|
+
summaries: summary ? [summary] : undefined
|
|
60
|
+
});
|
|
61
|
+
};
|
package/dist/schema.d.ts
ADDED
package/dist/schema.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// @butinapp/sdk/schema — the runtime zod schemas the HOST composes into the export-bundle / Overview wire
|
|
2
|
+
// schema (@butinapp/shapes). NOT the author surface: a plugin author annotates the data-view TYPES on
|
|
3
|
+
// @butinapp/sdk/data (`Summary`, `MonthPoint`, …) and never imports these schema objects. Kept off /data
|
|
4
|
+
// precisely so the author's autocomplete there carries only what a plugin declares.
|
|
5
|
+
export { MonthPointSchema } from './data/series.js';
|
|
6
|
+
export { SummarySchema, SectionSchema } from './data/summary.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// @butinapp/sdk/testing — the seeded synthetic-sample toolkit a plugin reaches for when authoring a
|
|
2
|
+
// capability's `sample`. `createSampleGen` builds a deterministic generator (a shared cast of fake people +
|
|
3
|
+
// synthetic primitives — emails are always `@example.invalid`, so a generated raw can NEVER carry real PII);
|
|
4
|
+
// `resolveSampleConfig` turns the size knobs into a concrete `SampleConfig`. Kept off `/util` (which is the
|
|
5
|
+
// runtime edge-normalizer surface) because this is demo/seed tooling, not request-path code.
|
|
6
|
+
export { createSampleGen, resolveSampleConfig } from './synthetic.js';
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export interface SamplePerson {
|
|
2
|
+
id: string;
|
|
3
|
+
firstName: string;
|
|
4
|
+
lastName: string;
|
|
5
|
+
name: string;
|
|
6
|
+
email: string;
|
|
7
|
+
}
|
|
8
|
+
export interface SampleConfig {
|
|
9
|
+
users: number;
|
|
10
|
+
documents: number;
|
|
11
|
+
days: number;
|
|
12
|
+
window: number;
|
|
13
|
+
}
|
|
14
|
+
export type SampleSize = 'small' | 'medium' | 'large' | 'xlarge';
|
|
15
|
+
export interface SampleDay {
|
|
16
|
+
date: string;
|
|
17
|
+
iso: string;
|
|
18
|
+
epochMs: number;
|
|
19
|
+
epochSec: number;
|
|
20
|
+
yearMonth: string;
|
|
21
|
+
}
|
|
22
|
+
export interface SampleMonth {
|
|
23
|
+
yearMonth: string;
|
|
24
|
+
ym: string;
|
|
25
|
+
startEpochMs: number;
|
|
26
|
+
startEpochSec: number;
|
|
27
|
+
label: string;
|
|
28
|
+
}
|
|
29
|
+
export interface SampleGen {
|
|
30
|
+
person: (i?: number) => SamplePerson;
|
|
31
|
+
people: (n: number) => SamplePerson[];
|
|
32
|
+
company: () => string;
|
|
33
|
+
orgSlug: () => string;
|
|
34
|
+
id: (prefix?: string) => string;
|
|
35
|
+
int: (min: number, max: number) => number;
|
|
36
|
+
float: (min: number, max: number, decimals?: number) => number;
|
|
37
|
+
bool: (p?: number) => boolean;
|
|
38
|
+
pick: <T>(items: readonly T[]) => T;
|
|
39
|
+
maybe: <T>(value: T, p?: number) => T | undefined;
|
|
40
|
+
money: (min: number, max: number) => number;
|
|
41
|
+
amountCents: (min: number, max: number) => number;
|
|
42
|
+
pastDate: (maxDaysAgo: number) => string;
|
|
43
|
+
dayString: (maxDaysAgo: number) => string;
|
|
44
|
+
words: (n: number) => string;
|
|
45
|
+
sentence: () => string;
|
|
46
|
+
maskedKey: () => string;
|
|
47
|
+
last4: () => string;
|
|
48
|
+
phone: () => string;
|
|
49
|
+
postal: () => string;
|
|
50
|
+
address: () => string;
|
|
51
|
+
day: (maxDaysAgo: number) => SampleDay;
|
|
52
|
+
midnightIso: (maxDaysAgo: number) => string;
|
|
53
|
+
pastEpochMs: (maxDaysAgo: number) => number;
|
|
54
|
+
pastEpochSec: (maxDaysAgo: number) => number;
|
|
55
|
+
monthsAgo: (n: number) => SampleMonth;
|
|
56
|
+
url: (segment: string, id?: string | number) => string;
|
|
57
|
+
moneyStr: (min: number, max: number, decimals?: number) => string;
|
|
58
|
+
seqId: (prefix: string, n: number, width?: number) => string;
|
|
59
|
+
repeat: <T>(n: number, fn: (i: number, g: SampleGen) => T) => T[];
|
|
60
|
+
}
|
|
61
|
+
export type SampleGenerator<TRaw> = (g: SampleGen, config: SampleConfig) => TRaw;
|
|
62
|
+
export declare const createSampleGen: (seed: string) => SampleGen;
|
|
63
|
+
export declare const resolveSampleConfig: (input?: Partial<SampleConfig> & {
|
|
64
|
+
size?: SampleSize;
|
|
65
|
+
}) => SampleConfig;
|