@hanzo/build 0.2.20 → 0.2.21

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.
@@ -18,6 +18,7 @@
18
18
  * POST /v1/billing/topup charge a saved card into the balance
19
19
  * GET /v1/billing/usage/rollup the plan's month: included spend and request windows
20
20
  * GET /v1/usage/summary?range=month this month's spend, by category
21
+ * GET /v1/allowance the Free plan's allowance, and the pool it is served from
21
22
  * GET /v1/billing/alerts spend caps (bare array)
22
23
  * POST /v1/billing/alerts open a cap (org admin)
23
24
  * PATCH /v1/billing/alerts/{id} change one (org admin)
@@ -183,6 +184,35 @@ export interface Spend {
183
184
  }[];
184
185
  }
185
186
  export declare function spend(t: Target): Promise<Spend>;
187
+ /** The pool every free user shares, as its vendor accounts last said. */
188
+ export interface Pool {
189
+ /** available, busy or exhausted. */
190
+ state: string;
191
+ /** Accounts in the pool, and how many take a free request now. */
192
+ keys: number;
193
+ ready: number;
194
+ /** RFC 3339: when the first account that is out comes back; '' when none is out. */
195
+ resets: string;
196
+ }
197
+ /**
198
+ * What the caller has left of the Free plan: limited usage, from a pool shared
199
+ * by all free users. `pooled` is false for a paid plan, whose usage is its spend.
200
+ */
201
+ export interface Allowance {
202
+ plan: string;
203
+ /** Requests allowed in `window`; 0 is no bound. */
204
+ limit: number;
205
+ used: number;
206
+ spent: boolean;
207
+ /** hour or day. */
208
+ window: string;
209
+ /** RFC 3339. */
210
+ resets: string;
211
+ pooled: boolean;
212
+ /** Absent when the platform could not read it. */
213
+ pool: Pool | null;
214
+ }
215
+ export declare function allowance(t: Target): Promise<Allowance>;
186
216
  export interface Cap {
187
217
  id: string;
188
218
  title: string;
@@ -18,6 +18,7 @@
18
18
  * POST /v1/billing/topup charge a saved card into the balance
19
19
  * GET /v1/billing/usage/rollup the plan's month: included spend and request windows
20
20
  * GET /v1/usage/summary?range=month this month's spend, by category
21
+ * GET /v1/allowance the Free plan's allowance, and the pool it is served from
21
22
  * GET /v1/billing/alerts spend caps (bare array)
22
23
  * POST /v1/billing/alerts open a cap (org admin)
23
24
  * PATCH /v1/billing/alerts/{id} change one (org admin)
@@ -256,6 +257,21 @@ export async function spend(t) {
256
257
  .filter((c) => c.cents > 0),
257
258
  };
258
259
  }
260
+ const stamp = (unix) => (num(unix) > 0 ? new Date(num(unix) * 1000).toISOString() : '');
261
+ export async function allowance(t) {
262
+ const r = obj(await call(t, 'GET', '/v1/allowance'));
263
+ const p = r.pool && typeof r.pool === 'object' ? obj(r.pool) : null;
264
+ return {
265
+ plan: str(r.plan),
266
+ limit: num(r.limit),
267
+ used: num(r.used),
268
+ spent: r.spent === true,
269
+ window: str(r.window),
270
+ resets: stamp(r.resets),
271
+ pooled: r.pooled === true,
272
+ pool: p && str(p.state) ? { state: str(p.state), keys: num(p.keys), ready: num(p.ready), resets: stamp(p.resets) } : null,
273
+ };
274
+ }
259
275
  export function cap(raw) {
260
276
  const a = obj(raw);
261
277
  return {
@@ -8,7 +8,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
8
8
  import { SizableText, XStack, YStack } from '@hanzo/gui';
9
9
  import { Button, Input } from '@hanzo/ui';
10
10
  import { useState } from 'react';
11
- import { balance, caps, cents, chosen, credit, label, lift, limit, methods, money, month, monthly, spend, topup, } from '../api/billing.js';
11
+ import { allowance, balance, caps, cents, chosen, credit, label, lift, limit, methods, money, month, monthly, spend, topup, } from '../api/billing.js';
12
12
  import { useRead } from '../data.js';
13
13
  import { useHost, useTarget } from '../host.js';
14
14
  import { path } from '../route.js';
@@ -40,11 +40,21 @@ function resets(iso) {
40
40
  return `Resets in ${Math.floor(mins / 60)} hr ${mins % 60} min`;
41
41
  return `Resets ${day(iso)}`;
42
42
  }
43
+ const POOL = { available: 'Available', busy: 'Busy', exhausted: 'Exhausted' };
44
+ /** `Refills at 16:00 UTC`, for a window or the pool: the UTC time it opens again, with the date when not today. */
45
+ function refills(iso) {
46
+ const at = iso ? new Date(iso) : null;
47
+ if (!at || Number.isNaN(at.getTime()))
48
+ return '';
49
+ const hhmm = `${String(at.getUTCHours()).padStart(2, '0')}:${String(at.getUTCMinutes()).padStart(2, '0')} UTC`;
50
+ return at.toISOString().slice(0, 10) === new Date().toISOString().slice(0, 10) ? `Refills at ${hhmm}` : `Refills ${day(iso)}, ${hhmm}`;
51
+ }
43
52
  export function Usage() {
44
53
  const host = useHost();
45
54
  const t = useTarget();
46
55
  const signed = Boolean(host.person);
47
56
  const plan = useRead(signed ? () => month(t) : null, null, [t, signed]);
57
+ const free = useRead(signed ? () => allowance(t) : null, null, [t, signed]);
48
58
  const left = useRead(signed ? () => balance(t) : null, 0, [t, signed]);
49
59
  const granted = useRead(signed ? () => credit(t) : null, 0, [t, signed]);
50
60
  const spent = useRead(signed ? () => spend(t) : null, null, [t, signed]);
@@ -103,7 +113,7 @@ export function Usage() {
103
113
  ceilings.reload();
104
114
  });
105
115
  };
106
- return (_jsxs(YStack, { gap: "$6", children: [_jsx(Heading, { title: "Usage", detail: `What ${host.org ?? 'this organization'} has used, has left, and may spend.` }), _jsx(Group, { title: "Plan usage limits", detail: m?.plan ? `Included with ${m.plan}.` : 'What the plan includes.', children: plan.error ? (_jsx(Soft, { children: plan.error.message })) : plan.loading && !m ? (_jsx(Soft, { children: "Reading the plan\u2019s limits\u2026" })) : !m || (windows.length === 0 && m.included === 0) ? (_jsx(Card, { children: _jsx(Soft, { children: "This plan sets no usage limits. Usage is paid from the balance." }) })) : (_jsxs(Card, { children: [m.included > 0 ? (_jsx(Meter, { first: true, title: "Included spend", used: m.used, of: m.included, says: `${money(m.used)} of ${money(m.included)}${m.overage ? ` · ${money(m.overage)} over` : ''}` })) : null, windows.map((w, i) => (_jsx(Meter, { first: i === 0 && m.included === 0, title: `${SPAN[w.span] ?? w.span} · ${w.used.toLocaleString()} of ${w.limit.toLocaleString()} requests`, used: w.used, of: w.limit, says: `${Math.round((Math.min(w.used, w.limit) / w.limit) * 100)}% used${resets(w.resets) ? ` · ${resets(w.resets)}` : ''}` }, w.span)))] })) }), _jsx(Group, { title: "Balance", detail: "Prepaid money pays for usage past the plan. Credits are spent first.", action: buying || !host.admin ? undefined : (_jsx(Button, { size: "sm", variant: "outline", onPress: () => setBuying(true), children: "Buy more" })), children: _jsxs(Card, { children: [_jsx(Row, { first: true, title: "Balance", detail: "What is left to spend", trailing: _jsx(Figure, { read: left }) }), _jsx(Row, { title: "Credits", detail: "Granted, and spent before the balance", trailing: _jsx(Figure, { read: granted }) }), _jsx(Row, { title: "Spent this month", detail: s && !s.known ? 'The ledger did not answer, so this is not a measurement' : undefined, trailing: _jsx(Figure, { read: { ...spent, value: s?.total ?? 0 } }) }), buying ? (_jsx(YStack, { gap: "$2", px: "$3", py: "$3", borderTopWidth: 1, borderColor: "$borderColor", children: card ? (_jsxs(_Fragment, { children: [_jsxs(XStack, { gap: "$2", items: "center", flexWrap: "wrap", children: [_jsx(YStack, { width: 140, children: _jsx(Input, { value: amount, onChangeText: setAmount, "aria-label": "Amount in dollars", placeholder: "25", inputMode: "decimal" }) }), _jsxs(SizableText, { flex: 1, minW: 160, size: "$1", color: "$soft", children: ["Charged to ", label(card)] })] }), _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "ghost", onPress: () => setBuying(false), children: "Cancel" }), _jsx(Button, { size: "sm", disabled: working, onPress: buy, children: adding ? `Add ${money(adding)}` : 'Add funds' })] })] })) : (_jsxs(XStack, { gap: "$2", items: "center", flexWrap: "wrap", children: [_jsx(SizableText, { flex: 1, minW: 160, size: "$2", color: "$soft", children: cards.loading ? 'Reading cards…' : 'Add a card in Billing to buy more.' }), _jsx(Button, { size: "sm", variant: "outline", onPress: () => host.go(path({ kind: 'settings', section: 'billing' })), children: "Billing" })] })) })) : null] }) }), _jsx(Group, { title: "Spend this month", detail: "By what it paid for.", children: spent.error ? (_jsx(Soft, { children: spent.error.message })) : !s ? (_jsx(Soft, { children: "Reading spend\u2026" })) : s.categories.length === 0 ? (_jsx(Card, { children: _jsx(Soft, { children: "Nothing spent this month." }) })) : (_jsx(Card, { children: s.categories.map((c, i) => (_jsx(Meter, { first: i === 0, title: c.name, used: c.cents, of: top, says: money(c.cents), limit: false }, c.name))) })) }), _jsxs(Group, { title: "Monthly limit", detail: "Spend past it is refused until the month starts over.", children: [ceilings.error ? (_jsx(Soft, { children: ceilings.error.message })) : (_jsxs(Card, { children: [cap ? (_jsx(Meter, { first: true, title: `${money(cap.threshold)} a month`, used: cap.spent ?? 0, of: cap.threshold, says: [cap.spent === null ? 'Spend unknown' : `${money(cap.spent)} spent`, resets(cap.resets)].filter(Boolean).join(' · ') })) : (_jsx(Row, { first: true, title: "No monthly limit", detail: "Spend is bounded by the balance alone." })), others.map((c) => (_jsx(Row, { title: c.title || 'Limit', detail: [c.project && `project ${c.project}`, c.service && `service ${c.service}`, c.enforce ? 'refuses spend past it' : 'warns only']
116
+ return (_jsxs(YStack, { gap: "$6", children: [_jsx(Heading, { title: "Usage", detail: `What ${host.org ?? 'this organization'} has used, has left, and may spend.` }), free.value?.pooled && free.value.limit > 0 ? (_jsx(Group, { title: "Free plan", detail: "Free \u2014 limited usage, from a pool shared by all free users.", action: _jsx(Button, { size: "sm", variant: "outline", onPress: () => host.go(path({ kind: 'screen', screen: 'plans' })), children: "Upgrade" }), children: _jsxs(Card, { children: [_jsx(Meter, { first: true, title: `${Math.max(0, free.value.limit - free.value.used).toLocaleString()} of ${free.value.limit.toLocaleString()} left ${free.value.window === 'hour' ? 'this hour' : free.value.window === 'day' ? 'today' : 'this period'}`, used: free.value.used, of: free.value.limit, says: [`${free.value.used.toLocaleString()} used`, refills(free.value.resets)].filter(Boolean).join(' · ') }), free.value.pool ? (_jsx(Row, { title: "Shared pool", detail: `${free.value.pool.ready} of ${free.value.pool.keys} accounts serving${free.value.pool.state === 'exhausted' && refills(free.value.pool.resets) ? ` · ${refills(free.value.pool.resets)}` : ''}`, trailing: _jsx(SizableText, { size: "$2", color: free.value.pool.state === 'exhausted' ? '$red10' : '$ink', children: POOL[free.value.pool.state] ?? free.value.pool.state }) })) : null] }) })) : null, _jsx(Group, { title: "Plan usage limits", detail: m?.plan ? `Included with ${m.plan}.` : 'What the plan includes.', children: plan.error ? (_jsx(Soft, { children: plan.error.message })) : plan.loading && !m ? (_jsx(Soft, { children: "Reading the plan\u2019s limits\u2026" })) : !m || (windows.length === 0 && m.included === 0) ? (_jsx(Card, { children: _jsx(Soft, { children: "This plan sets no usage limits. Usage is paid from the balance." }) })) : (_jsxs(Card, { children: [m.included > 0 ? (_jsx(Meter, { first: true, title: "Included spend", used: m.used, of: m.included, says: `${money(m.used)} of ${money(m.included)}${m.overage ? ` · ${money(m.overage)} over` : ''}` })) : null, windows.map((w, i) => (_jsx(Meter, { first: i === 0 && m.included === 0, title: `${SPAN[w.span] ?? w.span} · ${w.used.toLocaleString()} of ${w.limit.toLocaleString()} requests`, used: w.used, of: w.limit, says: `${Math.round((Math.min(w.used, w.limit) / w.limit) * 100)}% used${resets(w.resets) ? ` · ${resets(w.resets)}` : ''}` }, w.span)))] })) }), _jsx(Group, { title: "Balance", detail: "Prepaid money pays for usage past the plan. Credits are spent first.", action: buying || !host.admin ? undefined : (_jsx(Button, { size: "sm", variant: "outline", onPress: () => setBuying(true), children: "Buy more" })), children: _jsxs(Card, { children: [_jsx(Row, { first: true, title: "Balance", detail: "What is left to spend", trailing: _jsx(Figure, { read: left }) }), _jsx(Row, { title: "Credits", detail: "Granted, and spent before the balance", trailing: _jsx(Figure, { read: granted }) }), _jsx(Row, { title: "Spent this month", detail: s && !s.known ? 'The ledger did not answer, so this is not a measurement' : undefined, trailing: _jsx(Figure, { read: { ...spent, value: s?.total ?? 0 } }) }), buying ? (_jsx(YStack, { gap: "$2", px: "$3", py: "$3", borderTopWidth: 1, borderColor: "$borderColor", children: card ? (_jsxs(_Fragment, { children: [_jsxs(XStack, { gap: "$2", items: "center", flexWrap: "wrap", children: [_jsx(YStack, { width: 140, children: _jsx(Input, { value: amount, onChangeText: setAmount, "aria-label": "Amount in dollars", placeholder: "25", inputMode: "decimal" }) }), _jsxs(SizableText, { flex: 1, minW: 160, size: "$1", color: "$soft", children: ["Charged to ", label(card)] })] }), _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "ghost", onPress: () => setBuying(false), children: "Cancel" }), _jsx(Button, { size: "sm", disabled: working, onPress: buy, children: adding ? `Add ${money(adding)}` : 'Add funds' })] })] })) : (_jsxs(XStack, { gap: "$2", items: "center", flexWrap: "wrap", children: [_jsx(SizableText, { flex: 1, minW: 160, size: "$2", color: "$soft", children: cards.loading ? 'Reading cards…' : 'Add a card in Billing to buy more.' }), _jsx(Button, { size: "sm", variant: "outline", onPress: () => host.go(path({ kind: 'settings', section: 'billing' })), children: "Billing" })] })) })) : null] }) }), _jsx(Group, { title: "Spend this month", detail: "By what it paid for.", children: spent.error ? (_jsx(Soft, { children: spent.error.message })) : !s ? (_jsx(Soft, { children: "Reading spend\u2026" })) : s.categories.length === 0 ? (_jsx(Card, { children: _jsx(Soft, { children: "Nothing spent this month." }) })) : (_jsx(Card, { children: s.categories.map((c, i) => (_jsx(Meter, { first: i === 0, title: c.name, used: c.cents, of: top, says: money(c.cents), limit: false }, c.name))) })) }), _jsxs(Group, { title: "Monthly limit", detail: "Spend past it is refused until the month starts over.", children: [ceilings.error ? (_jsx(Soft, { children: ceilings.error.message })) : (_jsxs(Card, { children: [cap ? (_jsx(Meter, { first: true, title: `${money(cap.threshold)} a month`, used: cap.spent ?? 0, of: cap.threshold, says: [cap.spent === null ? 'Spend unknown' : `${money(cap.spent)} spent`, resets(cap.resets)].filter(Boolean).join(' · ') })) : (_jsx(Row, { first: true, title: "No monthly limit", detail: "Spend is bounded by the balance alone." })), others.map((c) => (_jsx(Row, { title: c.title || 'Limit', detail: [c.project && `project ${c.project}`, c.service && `service ${c.service}`, c.enforce ? 'refuses spend past it' : 'warns only']
107
117
  .filter(Boolean)
108
118
  .join(' · '), trailing: _jsx(SizableText, { size: "$2", color: "$ink", children: c.threshold ? money(c.threshold) : '—' }) }, c.id))), host.admin ? (_jsxs(XStack, { gap: "$2", items: "center", px: "$3", py: "$3", borderTopWidth: 1, borderColor: "$borderColor", flexWrap: "wrap", children: [_jsx(YStack, { width: 160, children: _jsx(Input, { value: ceiling, onChangeText: setCeiling, "aria-label": "Monthly limit in dollars", placeholder: cap ? String(cap.threshold / 100) : '500', inputMode: "decimal" }) }), _jsxs(XStack, { flex: 1, gap: "$2", justify: "flex-end", children: [cap ? (_jsx(Button, { size: "sm", variant: "ghost", disabled: working, onPress: () => void act(() => lift(t, cap.id), 'The monthly limit is lifted', ceilings.reload), children: "Remove" })) : null, _jsx(Button, { size: "sm", variant: "outline", disabled: working || !ceiling.trim(), onPress: setLimit, children: cap ? 'Change limit' : 'Set limit' })] })] })) : null] })), !host.admin ? (_jsx(SizableText, { size: "$1", color: "$soft", children: "An org admin sets the monthly limit." })) : null] }), _jsx(Note, { children: note })] }));
109
119
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/build",
3
- "version": "0.2.20",
3
+ "version": "0.2.21",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0 OR MIT",
6
6
  "description": "Hanzo Build — say what you want, watch it built, deployed and running. The builder as a component, mounted by the Hanzo app at hanzo.ai.",