moshcode 0.70.0 → 0.72.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/.claude-plugin/marketplace.json +24 -0
- package/README.md +120 -0
- package/bin/moshcode.mjs +46 -0
- package/package.json +1 -1
- package/plugins/billing/.claude-plugin/plugin.json +13 -0
- package/plugins/billing/README.md +42 -0
- package/plugins/billing/commands/hours.md +37 -0
- package/plugins/billing/commands/invoice.md +39 -0
- package/plugins/billing/commands/rate.md +40 -0
- package/plugins/billing/commands/report.md +28 -0
- package/plugins/timer/.claude-plugin/plugin.json +13 -0
- package/plugins/timer/README.md +36 -0
- package/plugins/timer/commands/report.md +32 -0
- package/plugins/timer/commands/start.md +33 -0
- package/plugins/timer/commands/status.md +27 -0
- package/plugins/timer/commands/stop.md +30 -0
- package/prd/0012-billing-baked-into-the-agent-cli.md +129 -0
- package/src/billing.mjs +363 -0
- package/src/business-delegate.mjs +105 -0
- package/src/business-store.mjs +155 -0
- package/src/cli-schema.mjs +245 -0
- package/src/clients.mjs +328 -0
- package/src/commands.mjs +9 -0
- package/src/payments.mjs +258 -0
- package/src/plugins.mjs +39 -7
- package/src/rates.mjs +368 -0
- package/src/teams.mjs +459 -0
- package/src/timer.mjs +354 -0
- package/src/tools.mjs +18 -0
- package/src/tui.mjs +77 -0
package/src/rates.mjs
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
// What an hour of agent time costs, written the way people say it out loud.
|
|
2
|
+
//
|
|
3
|
+
// /rate set acme $100/hour/agent/upto:4
|
|
4
|
+
//
|
|
5
|
+
// One line that carries four decisions: the price, the period it is charged
|
|
6
|
+
// for, the thing that is multiplied (an agent, a seat, a person), and the point
|
|
7
|
+
// past which you stop charging. Rate cards get written down as prose in a
|
|
8
|
+
// contract and then re-derived by hand at invoice time; this makes the prose
|
|
9
|
+
// itself the machine-readable form, so `/billing` does the arithmetic from the
|
|
10
|
+
// same words the client agreed to.
|
|
11
|
+
//
|
|
12
|
+
// Settlement currency is deliberately separate from the price. "$100/hour paid
|
|
13
|
+
// in USDC" is one rate with a preference, not two rates — the number in the
|
|
14
|
+
// contract does not change because the rail did.
|
|
15
|
+
import { loadBusiness, updateBusiness } from "./business-store.mjs";
|
|
16
|
+
import { resolveClient } from "./clients.mjs";
|
|
17
|
+
import { acid, ash, bone, err, info, ok, table } from "./ui.mjs";
|
|
18
|
+
|
|
19
|
+
/** Periods a rate can be charged per. `project` and `task` are flat fees. */
|
|
20
|
+
export const PERIODS = ["hour", "day", "week", "month", "project", "task"];
|
|
21
|
+
|
|
22
|
+
/** What gets multiplied. `flat` means the price is not per-anything. */
|
|
23
|
+
export const UNITS = ["agent", "seat", "person", "team", "flat"];
|
|
24
|
+
|
|
25
|
+
/** Hours in each period, for converting tracked time into billable units. */
|
|
26
|
+
export const PERIOD_HOURS = { hour: 1, day: 8, week: 40, month: 160 };
|
|
27
|
+
|
|
28
|
+
/** Currency symbols worth understanding, and the code each means. */
|
|
29
|
+
const SYMBOLS = { $: "USD", "€": "EUR", "£": "GBP", "¥": "JPY" };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Codes that are money in the ISO sense — the ones Intl can format.
|
|
33
|
+
*
|
|
34
|
+
* The list matters because everything *not* on it is formatted as a bare
|
|
35
|
+
* number and a ticker (`250 USDC`), which is how crypto amounts are read
|
|
36
|
+
* everywhere else. Intl.NumberFormat would happily accept "USDC" and then
|
|
37
|
+
* render "USDC 250.00", which is nobody's idea of a price.
|
|
38
|
+
*/
|
|
39
|
+
const FIAT = new Set(["USD", "EUR", "GBP", "JPY", "CAD", "AUD", "CHF", "SEK", "NOK", "NZD"]);
|
|
40
|
+
|
|
41
|
+
/** Tickers we accept without a symbol. Not exhaustive — an unknown code is kept as typed. */
|
|
42
|
+
const KNOWN_CRYPTO = new Set(["SOL", "USDC", "USDT", "BTC", "ETH", "MATIC", "BNB", "XRP", "DOGE", "LTC", "AVAX", "ADA"]);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Stablecoins pegged 1:1 to the dollar.
|
|
46
|
+
*
|
|
47
|
+
* They matter because a gateway's invoice usually carries a fiat amount and a
|
|
48
|
+
* separate settlement ticker. "250 USDC" is a $250 invoice settled in USDC and
|
|
49
|
+
* can be expressed that way honestly; "1.5 SOL" is not $1.50 or $150 or any
|
|
50
|
+
* other number we know, and pretending otherwise would put a wrong figure in
|
|
51
|
+
* front of a client. So the peg is written down rather than assumed.
|
|
52
|
+
*/
|
|
53
|
+
const DOLLAR_PEGGED = new Set(["USDC", "USDT", "DAI", "PYUSD", "USDP", "TUSD"]);
|
|
54
|
+
|
|
55
|
+
export function isFiat(code) {
|
|
56
|
+
return FIAT.has(String(code || "").toUpperCase());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isDollarPegged(code) {
|
|
60
|
+
return DOLLAR_PEGGED.has(String(code || "").toUpperCase());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Render an amount the way its currency is normally written.
|
|
65
|
+
*
|
|
66
|
+
* Fiat goes through Intl (symbol, grouping, two decimals); anything else is
|
|
67
|
+
* `<amount> <TICKER>`, trimmed of trailing zeros — 0.5 SOL is 0.5 SOL, not
|
|
68
|
+
* 0.50 SOL, and a USDC total of 1250 should not read as 1,250.00 USDC.
|
|
69
|
+
*/
|
|
70
|
+
export function formatMoney(amount, currency = "USD") {
|
|
71
|
+
const code = String(currency || "USD").toUpperCase();
|
|
72
|
+
const n = Number(amount);
|
|
73
|
+
if (!Number.isFinite(n)) return `— ${code}`;
|
|
74
|
+
if (isFiat(code)) {
|
|
75
|
+
return new Intl.NumberFormat("en-US", { style: "currency", currency: code }).format(n);
|
|
76
|
+
}
|
|
77
|
+
// Up to 8 decimals so a BTC figure survives, but no padding: crypto amounts
|
|
78
|
+
// are read as quantities, and "0.10000000 BTC" hides the number in zeros.
|
|
79
|
+
const fixed = n.toFixed(8).replace(/\.?0+$/, "");
|
|
80
|
+
return `${fixed} ${code}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseAmount(token) {
|
|
84
|
+
const raw = String(token || "").trim();
|
|
85
|
+
if (!raw) return null;
|
|
86
|
+
// "$100", "100USD", "100 USD", "USDC250", "0.5 SOL" — one shape, read from
|
|
87
|
+
// both ends, because all four spellings turn up in the same conversation.
|
|
88
|
+
const symbol = SYMBOLS[raw[0]];
|
|
89
|
+
const body = symbol ? raw.slice(1) : raw;
|
|
90
|
+
const m = body.match(/^([a-z]{2,5})?\s*([0-9][0-9_,]*(?:\.[0-9]+)?)\s*([a-z]{2,5})?$/i);
|
|
91
|
+
if (!m) return null;
|
|
92
|
+
const amount = Number(m[2].replace(/[_,]/g, ""));
|
|
93
|
+
if (!Number.isFinite(amount) || amount < 0) return null;
|
|
94
|
+
const code = (m[1] || m[3] || "").toUpperCase();
|
|
95
|
+
if (code && !isFiat(code) && !KNOWN_CRYPTO.has(code) && !/^[A-Z]{3,5}$/.test(code)) return null;
|
|
96
|
+
return { amount, currency: code || symbol || "USD" };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Parse a rate spec into `{ amount, currency, per, unit, cap, min }`, or throw.
|
|
101
|
+
*
|
|
102
|
+
* The grammar is positional only in its first segment (the price); everything
|
|
103
|
+
* after it is recognised by what it says rather than where it sits, so
|
|
104
|
+
* `$100/agent/hour` and `$100/hour/agent` mean the same thing. People do not
|
|
105
|
+
* remember an order they were never told.
|
|
106
|
+
*/
|
|
107
|
+
export function parseRate(spec) {
|
|
108
|
+
const text = String(spec ?? "").trim();
|
|
109
|
+
if (!text) throw new Error("a rate looks like $100/hour/agent/upto:4");
|
|
110
|
+
const parts = text.split("/").map((p) => p.trim()).filter(Boolean);
|
|
111
|
+
const price = parseAmount(parts.shift());
|
|
112
|
+
if (!price) throw new Error(`can't read a price out of ${JSON.stringify(text)} — try $100/hour/agent`);
|
|
113
|
+
|
|
114
|
+
const rate = { ...price, per: "hour", unit: "flat", cap: null, min: null };
|
|
115
|
+
let sawPeriod = false;
|
|
116
|
+
for (const part of parts) {
|
|
117
|
+
const [key, value] = part.split(":").map((s) => s.trim().toLowerCase());
|
|
118
|
+
if (["upto", "up-to", "max", "cap"].includes(key)) {
|
|
119
|
+
const n = Number(value);
|
|
120
|
+
if (!Number.isInteger(n) || n < 1) throw new Error(`upto: wants a whole number of units, got ${JSON.stringify(value ?? "")}`);
|
|
121
|
+
rate.cap = n;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (["min", "minimum", "floor"].includes(key)) {
|
|
125
|
+
const n = Number(value);
|
|
126
|
+
if (!Number.isFinite(n) || n < 0) throw new Error(`min: wants a number, got ${JSON.stringify(value ?? "")}`);
|
|
127
|
+
rate.min = n;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const word = key.replace(/s$/, "");
|
|
131
|
+
if (PERIODS.includes(word)) { rate.per = word; sawPeriod = true; continue; }
|
|
132
|
+
if (word === "hr") { rate.per = "hour"; sawPeriod = true; continue; }
|
|
133
|
+
if (word === "mo") { rate.per = "month"; sawPeriod = true; continue; }
|
|
134
|
+
if (word === "yr" || word === "year") { rate.per = "month"; rate.amount /= 12; sawPeriod = true; continue; }
|
|
135
|
+
if (UNITS.includes(word)) { rate.unit = word; continue; }
|
|
136
|
+
if (word === "head" || word === "dev" || word === "engineer") { rate.unit = "person"; continue; }
|
|
137
|
+
throw new Error(`don't know what ${JSON.stringify(part)} means in a rate — periods: ${PERIODS.join("/")}, units: ${UNITS.join("/")}, or upto:N`);
|
|
138
|
+
}
|
|
139
|
+
// A flat fee with no period stated is a project fee, not an hourly one: "$5000
|
|
140
|
+
// for the project" is how it is written, and defaulting it to per-hour would
|
|
141
|
+
// silently multiply the invoice by every hour tracked.
|
|
142
|
+
if (!sawPeriod && rate.unit === "flat" && rate.cap === null) rate.per = "hour";
|
|
143
|
+
if (rate.cap !== null && rate.unit === "flat") {
|
|
144
|
+
throw new Error("upto: caps a unit, so say what it caps — $100/hour/agent/upto:4");
|
|
145
|
+
}
|
|
146
|
+
return rate;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The canonical spelling of a parsed rate — round-trips through parseRate. */
|
|
150
|
+
export function formatRate(rate) {
|
|
151
|
+
if (!rate) return "—";
|
|
152
|
+
const bits = [formatMoney(rate.amount, rate.currency), rate.per];
|
|
153
|
+
if (rate.unit && rate.unit !== "flat") bits.push(rate.unit);
|
|
154
|
+
if (rate.cap) bits.push(`upto:${rate.cap}`);
|
|
155
|
+
if (rate.min) bits.push(`min:${rate.min}`);
|
|
156
|
+
return bits.join("/");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** How a rate reads in a sentence, for confirmations and invoices. */
|
|
160
|
+
export function describeRate(rate) {
|
|
161
|
+
if (!rate) return "no rate set";
|
|
162
|
+
const price = formatMoney(rate.amount, rate.currency);
|
|
163
|
+
const per = rate.per === "project" || rate.per === "task" ? `per ${rate.per}` : `per ${rate.per}`;
|
|
164
|
+
const unit = rate.unit && rate.unit !== "flat" ? ` per ${rate.unit}` : "";
|
|
165
|
+
const cap = rate.cap ? `, billing at most ${rate.cap} ${rate.unit}${rate.cap === 1 ? "" : "s"}` : "";
|
|
166
|
+
const settle = settlementNote(rate);
|
|
167
|
+
return `${price} ${per}${unit}${cap}${settle ? ` (${settle})` : ""}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** "prefers SOL or USDC, fiat accepted" — or "" when nothing was stated. */
|
|
171
|
+
export function settlementNote(rate) {
|
|
172
|
+
const prefer = rate?.prefer || [];
|
|
173
|
+
const accept = rate?.accept || [];
|
|
174
|
+
if (!prefer.length && !accept.length) return "";
|
|
175
|
+
const bits = [];
|
|
176
|
+
if (prefer.length) bits.push(`prefers ${prefer.join(" or ")}`);
|
|
177
|
+
if (accept.length) bits.push(`${accept.join(", ")} accepted`);
|
|
178
|
+
return bits.join(", ");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The rate that applies to a client: their own, else the default, else null.
|
|
183
|
+
*
|
|
184
|
+
* A default rate is the common case for a solo shop — one number, everybody
|
|
185
|
+
* pays it — and a per-client override is what happens the first time somebody
|
|
186
|
+
* negotiates. Neither should require restating the other.
|
|
187
|
+
*/
|
|
188
|
+
export function rateFor(business, clientId) {
|
|
189
|
+
const rates = business?.rates || {};
|
|
190
|
+
if (clientId && rates[clientId]) return { ...rates[clientId], source: clientId };
|
|
191
|
+
if (rates.default) return { ...rates.default, source: "default" };
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Which key a rate is filed under: `default`, or a real client id.
|
|
197
|
+
*
|
|
198
|
+
* Rates are looked up by client id, so a rate filed under a name that is not
|
|
199
|
+
* one — `/rate set acme …` when the client is `acme-inc` — is a rate that never
|
|
200
|
+
* applies to anything. It fails silently at exactly the wrong moment: the
|
|
201
|
+
* invoice comes out at the default rate and looks fine. So the target is
|
|
202
|
+
* resolved the same way `/timer on` and `/billing` resolve a client, and an
|
|
203
|
+
* unknown one is refused rather than filed somewhere nothing will read it.
|
|
204
|
+
*/
|
|
205
|
+
export function resolveRateTarget(business, token) {
|
|
206
|
+
const want = String(token ?? "").trim().toLowerCase();
|
|
207
|
+
if (!want || want === "default" || want === "*") return { ok: true, id: "default" };
|
|
208
|
+
return resolveClient(business, want);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Words that are categories rather than tickers, and stay lowercase. */
|
|
212
|
+
const SETTLEMENT_WORDS = new Set(["fiat", "crypto", "stablecoin", "any", "cash"]);
|
|
213
|
+
|
|
214
|
+
/** Split `--prefer sol,usdc --accept fiat` off an argv, returning both halves. */
|
|
215
|
+
export function splitSettlement(argv) {
|
|
216
|
+
const rest = [];
|
|
217
|
+
const out = { prefer: [], accept: [] };
|
|
218
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
219
|
+
const arg = argv[i];
|
|
220
|
+
if (arg === "--prefer" || arg === "--accept") {
|
|
221
|
+
const key = arg.slice(2);
|
|
222
|
+
const value = argv[i + 1];
|
|
223
|
+
if (value && !value.startsWith("--")) {
|
|
224
|
+
out[key] = value.split(",").map((s) => s.trim()).filter(Boolean)
|
|
225
|
+
.map((s) => (SETTLEMENT_WORDS.has(s.toLowerCase()) ? s.toLowerCase() : s.toUpperCase()));
|
|
226
|
+
i += 1;
|
|
227
|
+
}
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
rest.push(arg);
|
|
231
|
+
}
|
|
232
|
+
return { argv: rest, settlement: out };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const USAGE = [
|
|
236
|
+
"usage: /rate set <client|default> <spec> [--prefer SOL,USDC] [--accept fiat]",
|
|
237
|
+
" /rate [list] [--json] · /rate show <client> · /rate rm <client>",
|
|
238
|
+
" spec: $100/hour/agent/upto:4 · 0.5 SOL/day · $5000/project · 250 USDC/task",
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* `/rate` and `/rates`.
|
|
243
|
+
*
|
|
244
|
+
* `set:` with a colon is accepted because that is how it was first written down
|
|
245
|
+
* (`/rates set: $100/hour/agent/upto:4`), and refusing punctuation somebody
|
|
246
|
+
* already typed teaches them nothing.
|
|
247
|
+
*/
|
|
248
|
+
export function rateCommand(argv = [], { write = console.log } = {}) {
|
|
249
|
+
const args = [...argv];
|
|
250
|
+
const json = args.includes("--json");
|
|
251
|
+
const positional = args.filter((a) => a !== "--json");
|
|
252
|
+
const verb = (positional[0] || "list").replace(/:$/, "").toLowerCase();
|
|
253
|
+
|
|
254
|
+
if (["list", "ls", ""].includes(verb) && positional.length <= 1) {
|
|
255
|
+
const { rates } = loadBusiness();
|
|
256
|
+
const names = Object.keys(rates).sort();
|
|
257
|
+
if (json) { write(JSON.stringify(rates, null, 2)); return 0; }
|
|
258
|
+
if (!names.length) {
|
|
259
|
+
write(info("no rates yet."));
|
|
260
|
+
write(` ${acid("/rate set default $100/hour/agent/upto:4")}`);
|
|
261
|
+
return 0;
|
|
262
|
+
}
|
|
263
|
+
write(table(
|
|
264
|
+
names.map((name) => [bone(name), acid(formatRate(rates[name])), ash(settlementNote(rates[name]))]),
|
|
265
|
+
{ columns: ["who", "rate", "settlement"], indent: 2 },
|
|
266
|
+
));
|
|
267
|
+
return 0;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (verb === "set") {
|
|
271
|
+
const { argv: words, settlement } = splitSettlement(positional.slice(1));
|
|
272
|
+
if (!words.length) { USAGE.forEach(write); return 1; }
|
|
273
|
+
// `/rate set $100/hour` with no target is the default rate — the shape
|
|
274
|
+
// somebody types when they have exactly one price and no client list yet.
|
|
275
|
+
let target = "default";
|
|
276
|
+
let spec = words.join(" ");
|
|
277
|
+
if (words.length > 1 && !parseSafely(words[0])) {
|
|
278
|
+
const found = resolveRateTarget(loadBusiness(), words[0]);
|
|
279
|
+
if (!found.ok) {
|
|
280
|
+
write(err(`no client ${JSON.stringify(words[0])} — ${acid(`/client create ${words[0]}`)} first, or set the ${bone("default")} rate`));
|
|
281
|
+
return 1;
|
|
282
|
+
}
|
|
283
|
+
target = found.id;
|
|
284
|
+
spec = words.slice(1).join(" ");
|
|
285
|
+
}
|
|
286
|
+
let rate;
|
|
287
|
+
try { rate = parseRate(spec); }
|
|
288
|
+
catch (e) { write(err(String(e.message || e))); write(` ${ash(USAGE[2])}`); return 1; }
|
|
289
|
+
if (settlement.prefer.length) rate.prefer = settlement.prefer;
|
|
290
|
+
if (settlement.accept.length) rate.accept = settlement.accept;
|
|
291
|
+
updateBusiness((data) => { data.rates[target] = rate; });
|
|
292
|
+
write(ok(`${bone(target)} → ${acid(formatRate(rate))}`));
|
|
293
|
+
write(` ${ash(describeRate(rate))}`);
|
|
294
|
+
return 0;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (["show", "get"].includes(verb)) {
|
|
298
|
+
const business = loadBusiness();
|
|
299
|
+
const found = resolveRateTarget(business, positional[1] || "default");
|
|
300
|
+
const who = found.ok ? found.id : String(positional[1]).toLowerCase();
|
|
301
|
+
const rate = rateFor(business, who);
|
|
302
|
+
if (!rate) { write(err(`no rate for ${JSON.stringify(who)} and no default — /rate set ${who} $100/hour/agent`)); return 1; }
|
|
303
|
+
if (json) { write(JSON.stringify(rate, null, 2)); return 0; }
|
|
304
|
+
write(` ${bone(who)} ${acid(formatRate(rate))}${rate.source !== who ? ash(` (from ${rate.source})`) : ""}`);
|
|
305
|
+
write(` ${ash(describeRate(rate))}`);
|
|
306
|
+
return 0;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (["rm", "remove", "delete", "unset"].includes(verb)) {
|
|
310
|
+
if (!positional[1]) { write(err("usage: /rate rm <client|default>")); return 1; }
|
|
311
|
+
const found = resolveRateTarget(loadBusiness(), positional[1]);
|
|
312
|
+
// A rate can outlive the client it was filed under (`/client rm` drops the
|
|
313
|
+
// rate, but a hand-edited file need not have), so an unresolved name still
|
|
314
|
+
// gets to name a key here — this verb only ever removes.
|
|
315
|
+
const who = found.ok ? found.id : String(positional[1]).toLowerCase();
|
|
316
|
+
const existed = updateBusiness((data) => {
|
|
317
|
+
const had = Object.hasOwn(data.rates, who);
|
|
318
|
+
delete data.rates[who];
|
|
319
|
+
return had;
|
|
320
|
+
});
|
|
321
|
+
write(existed ? ok(`dropped the rate for ${bone(who)}`) : info(`no rate for ${JSON.stringify(who)}`));
|
|
322
|
+
return existed ? 0 : 1;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// A bare `/rate $100/hour` reads as "set my rate", and that is the only other
|
|
326
|
+
// thing the word can mean here.
|
|
327
|
+
if (parseSafely(positional.join(" "))) return rateCommand(["set", ...positional], { write });
|
|
328
|
+
|
|
329
|
+
write(err(`unknown /rate verb ${JSON.stringify(verb)}`));
|
|
330
|
+
USAGE.forEach(write);
|
|
331
|
+
return 1;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function parseSafely(spec) {
|
|
335
|
+
try { return parseRate(spec); }
|
|
336
|
+
catch { return null; }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* What one tracked stretch of work is worth: `{ hours, units, amount, currency, flat }`.
|
|
341
|
+
*
|
|
342
|
+
* The arithmetic everybody does in their head and gets wrong once a quarter.
|
|
343
|
+
* Two things make it more than a multiplication:
|
|
344
|
+
*
|
|
345
|
+
* - the cap. `$100/hour/agent/upto:4` means four agents cost four hundred an
|
|
346
|
+
* hour and *so do six* — the cap is the promise that made the client sign,
|
|
347
|
+
* and it has to be applied here rather than remembered at invoice time.
|
|
348
|
+
* - the floor. `min:1` bills a fifteen-minute call as an hour, which is the
|
|
349
|
+
* other half of the same contract.
|
|
350
|
+
*
|
|
351
|
+
* A flat fee (`$5000/project`) returns `flat: true` and no amount: it is not
|
|
352
|
+
* earned per entry, so it is the invoice's job to add it once. Returning zero
|
|
353
|
+
* here would quietly bill nothing for a project that was fully delivered.
|
|
354
|
+
*/
|
|
355
|
+
export function chargeFor({ seconds = 0, agents = 1 } = {}, rate) {
|
|
356
|
+
if (!rate) return null;
|
|
357
|
+
const currency = rate.currency || "USD";
|
|
358
|
+
if (rate.per === "project") return { hours: seconds / 3600, units: 1, amount: null, currency, flat: true, per: rate.per };
|
|
359
|
+
const units = rate.unit === "flat" ? 1 : Math.max(1, Math.min(Number(agents) || 1, rate.cap ?? Infinity));
|
|
360
|
+
if (rate.per === "task") {
|
|
361
|
+
return { hours: seconds / 3600, units, amount: rate.amount * units, currency, flat: false, per: rate.per };
|
|
362
|
+
}
|
|
363
|
+
const perHours = PERIOD_HOURS[rate.per] ?? 1;
|
|
364
|
+
let hours = seconds / 3600;
|
|
365
|
+
if (rate.min) hours = Math.max(hours, rate.min * perHours);
|
|
366
|
+
const periods = hours / perHours;
|
|
367
|
+
return { hours: seconds / 3600, billedHours: hours, units, amount: rate.amount * periods * units, currency, flat: false, per: rate.per };
|
|
368
|
+
}
|