moshcode 0.70.0 → 0.71.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/README.md CHANGED
@@ -62,6 +62,12 @@ or miss one that does. A test fails the build when it drifts.
62
62
  | `moshcode crypto` <br>`coins` | tools | crypto market data from advis0r.com |
63
63
  | `moshcode news` | tools | headlines from your feeds, or a search |
64
64
  | `moshcode rss` | tools | read the same headlines in a full-screen reader |
65
+ | `moshcode timer` | business | track time — on, off, and what it added up to |
66
+ | `moshcode client` <br>`business` `merchant` `customer` | business | who the work is for — clients, businesses, merchants |
67
+ | `moshcode team` <br>`teams` | business | who may do what on this machine |
68
+ | `moshcode rate` <br>`rates` | business | what an hour of agent time costs |
69
+ | `moshcode billing` <br>`invoice` | business | turn tracked time into an invoice |
70
+ | `moshcode payments` | business | the rail invoices go out on |
65
71
  | `moshcode plugin` <br>`plugins` | extend | install moshcode's slash commands into Claude Code |
66
72
  | `moshcode commands` | script | list built-in moshscript commands |
67
73
  | `moshcode completion` | extend | print a shell completion script |
@@ -937,6 +943,88 @@ event, and publishes it to the displayed relays. Both flows leave the final
937
943
  confirmation in the browser. If the pit is remote or headless, `/post` prints
938
944
  the composer URL instead.
939
945
 
946
+ ## Getting paid (`/timer`, `/client`, `/rate`, `/billing`, `/payments`)
947
+
948
+ Every agentic CLI helps you do the work. This one also bills for it. Six words,
949
+ each useful on its own — the timer needs no client, the rate needs no gateway
950
+ (PRD [0012](prd/0012-billing-baked-into-the-agent-cli.md)).
951
+
952
+ ```sh
953
+ moshcode timer on acme --task "batch payments" --agents auto # auto counts the herd
954
+ moshcode timer off # → 1h 12m, $480.00
955
+ moshcode timer log --week # this week's timesheet
956
+ ```
957
+
958
+ The timer is a stopwatch and a ledger in `~/.moshcode/timers.json`, and it knows
959
+ nothing about money. It does know about **agents**, which is what makes it
960
+ different from every other stopwatch: an hour of moshcode is an hour times
961
+ however many engines ran in it.
962
+
963
+ ```sh
964
+ moshcode client create "Acme Inc", https://acme.com, +1-555-0100
965
+ moshcode client create globex --contact.telephone +1-555-0200 --contact.name Jane
966
+ moshcode client payee acme-inc solana:9xQe… # where their payments land
967
+ ```
968
+
969
+ Contact details are written the way they arrive: the comma form for what you
970
+ pasted out of a signature, `--a.b` dotted flags for anything else. There is no
971
+ fixed field list — `--billing.po` works because it says what it means.
972
+ `/business`, `/merchant` and `/customer` are the same command.
973
+
974
+ ```sh
975
+ moshcode rate set default $100/hour/agent/upto:4
976
+ moshcode rate set acme-inc 0.5 SOL/day --prefer SOL,USDC --accept fiat
977
+ moshcode rate set initech $5000/project
978
+ ```
979
+
980
+ `$100/hour/agent/upto:4` is the sentence from the contract, parsed: price,
981
+ period, unit, and the cap that made the client sign. Four agents cost four
982
+ hundred an hour and **so do six**. Order after the price does not matter.
983
+
984
+ ```sh
985
+ moshcode billing acme-inc # a preview — writes nothing
986
+ moshcode billing acme-inc --month --mark # claim the time, record the invoice
987
+ moshcode billing acme-inc --send # compose the gateway command
988
+ moshcode billing acme-inc --send --yes # …and run it
989
+ ```
990
+
991
+ Two rules the shape enforces: time is never billed twice (an entry carries the
992
+ invoice that claimed it, and `--mark` is the only verb that writes), and nothing
993
+ settles to an address nobody chose — no payee and no wallet rail is a refusal,
994
+ not a guess.
995
+
996
+ ```sh
997
+ moshcode payments connect coinpay # runs `coinpay login`
998
+ moshcode payments connect wallet --chain solana --address 9xQe… # no gateway at all
999
+ moshcode payments connect paypal --vault profullstack--prod # keys live in the vault
1000
+ ```
1001
+
1002
+ moshcode composes an invoice; a gateway delivers it. No secret is stored here: a
1003
+ CLI gateway keeps its own session, and an OAuth gateway gets a reference to the
1004
+ vault its keys live in (`moshcode secrets`), never the keys.
1005
+
1006
+ ### Teams and grants (`/team`)
1007
+
1008
+ For a machine you handed to somebody else:
1009
+
1010
+ ```sh
1011
+ moshcode team create Profullstack
1012
+ moshcode team add profullstack preshy --role member --rate '$80/hour'
1013
+ moshcode team grant profullstack preshy tools:coinpay
1014
+ moshcode team can profullstack/preshy payments:write # → no
1015
+ ```
1016
+
1017
+ A permission is `surface:target`, written however you say it — `tools:coinpay`,
1018
+ `tools/coinpay` and `allow(tools/coinpay)` are one grant. Roles (`owner`,
1019
+ `admin`, `member`, `client`) are a starting set; grants add to them.
1020
+
1021
+ The pit gates itself only when `MOSHCODE_MEMBER=<team>/<handle>` is set — with
1022
+ it unset the owner is at the keyboard and nothing is checked. **This is a
1023
+ guardrail, not a security boundary.** moshcode runs as the person at the
1024
+ keyboard, and anyone who can type `/team` can also edit
1025
+ `~/.moshcode/business.json`. A boundary that has to hold against somebody is an
1026
+ OS account, a container, or a scoped credential.
1027
+
940
1028
  ## The arcade (`/games`)
941
1029
 
942
1030
  Twenty-two games, in the pit or straight from a shell. There are no menus, no options
package/bin/moshcode.mjs CHANGED
@@ -418,6 +418,40 @@ async function main() {
418
418
  if (code) process.exitCode = code;
419
419
  return;
420
420
  }
421
+ // The business layer. Imported where they are dispatched rather than at the
422
+ // top of the file: six modules that read two JSON files are dead weight in
423
+ // the startup path of `moshcode claude`, which is what this binary is mostly
424
+ // asked to do.
425
+ if (cmd === "timer") {
426
+ const { timerCommand } = await import("../src/timer.mjs");
427
+ process.exitCode = (await timerCommand(rest)) || 0;
428
+ return;
429
+ }
430
+ if (cmd === "client" || cmd === "business" || cmd === "merchant" || cmd === "customer") {
431
+ const { clientCommand } = await import("../src/clients.mjs");
432
+ process.exitCode = clientCommand(rest) || 0;
433
+ return;
434
+ }
435
+ if (cmd === "team" || cmd === "teams") {
436
+ const { teamCommand } = await import("../src/teams.mjs");
437
+ process.exitCode = teamCommand(rest) || 0;
438
+ return;
439
+ }
440
+ if (cmd === "rate" || cmd === "rates") {
441
+ const { rateCommand } = await import("../src/rates.mjs");
442
+ process.exitCode = rateCommand(rest) || 0;
443
+ return;
444
+ }
445
+ if (cmd === "billing" || cmd === "invoice") {
446
+ const { billingCommand } = await import("../src/billing.mjs");
447
+ process.exitCode = billingCommand(rest) || 0;
448
+ return;
449
+ }
450
+ if (cmd === "payments") {
451
+ const { paymentsCommand } = await import("../src/payments.mjs");
452
+ process.exitCode = paymentsCommand(rest) || 0;
453
+ return;
454
+ }
421
455
  if (cmd === "games" || cmd === "game" || cmd === "arcade") {
422
456
  const code = await gamesCommand(rest);
423
457
  if (code) process.exitCode = code;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moshcode",
3
- "version": "0.70.0",
3
+ "version": "0.71.0",
4
4
  "type": "module",
5
5
  "description": "moshcode — a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript",
6
6
  "repository": {
@@ -0,0 +1,129 @@
1
+ ---
2
+ openprd: "0.2"
3
+ id: "0012"
4
+ title: "Bake billing into the agent CLI — timer, clients, teams, rates, invoices, rails"
5
+ status: Draft
6
+ authors:
7
+ - anthony@profullstack.com
8
+ created: 2026-08-29
9
+ updated: 2026-08-29
10
+ repo: https://github.com/moshcoder/moshcode
11
+ discussion:
12
+ implementation: src/timer.mjs · src/clients.mjs · src/teams.mjs · src/rates.mjs · src/billing.mjs · src/payments.mjs · src/business-store.mjs
13
+ tags: business, billing, payments, time-tracking, permissions
14
+ supersedes:
15
+ superseded-by:
16
+ ---
17
+
18
+ ## Problem
19
+
20
+ Every agentic CLI helps you do the work. None of them help you get paid for it.
21
+
22
+ That gap is not small. An agency running moshcode has the whole engagement in
23
+ this terminal — the engines, the herd, the repos, the deploys — and then leaves
24
+ it to bill: hours reconstructed from memory into a spreadsheet, a rate that
25
+ lives in a signed PDF nobody opens, an invoice retyped into a processor's web
26
+ form. The one system that actually knows how long four agents ran on Acme's
27
+ repo last Tuesday is the one system with nothing to say about it.
28
+
29
+ It is worse than clerical. Agent work prices differently from human work and the
30
+ existing tools cannot express it: an hour of moshcode is an hour times however
31
+ many engines were running in it, capped at whatever the client was promised.
32
+ "$100/hour/agent, up to 4" is a real sentence in a real contract, and there is
33
+ nowhere to write it down except prose.
34
+
35
+ The same terminal has a second unanswered question. A devops shop puts moshcode
36
+ on machines its own people and its clients sit at, and "Preshy can use the
37
+ CoinPay tool, the client can read invoices and nothing else" has no expression
38
+ anywhere — not in moshcode, not in the tools it wraps.
39
+
40
+ ## Goals
41
+
42
+ - Somebody who bills nobody still wants the timer, and gets it: `/timer on`,
43
+ `/timer off`, and a ledger, with no client, no rate and no gateway.
44
+ - The rate card is written in the words of the contract and read by the machine
45
+ — including the cap, which is the clause that made the client sign.
46
+ - The hours behind an invoice are the hours that were tracked, not the hours
47
+ somebody remembered on the last day of the month.
48
+ - Time is never billed twice, and money never settles to an address nobody
49
+ chose.
50
+ - Which processor a business already uses is their decision, not moshcode's.
51
+ - The split between an operator, an employee and a client is something an
52
+ operator can write down, and something the pit then respects.
53
+
54
+ ## Non-Goals
55
+
56
+ - Moving money. moshcode composes an invoice; a gateway delivers it. There is no
57
+ wallet, no key and no signing in this layer.
58
+ - Being an accounting system. No ledgers, no tax, no reconciliation, no
59
+ multi-currency FX at settlement time.
60
+ - Being a security boundary. The team gate stops the wrong command; it does not
61
+ stop a person with a shell. That is an OS account or a container, and this
62
+ PRD says so out loud rather than implying otherwise.
63
+ - Server-side state. Everything here is two JSON files under `~/.moshcode`.
64
+ A shared, multi-machine business record is a later question.
65
+
66
+ ## Users
67
+
68
+ - **The solo operator.** Bills a handful of clients hourly, wants the invoice to
69
+ be the time that was actually tracked.
70
+ - **The agency.** Several clients, several people, several rates, and a promise
71
+ about agent caps that has to survive contact with a busy month.
72
+ - **The employee or contractor.** Sits at a machine somebody else set up, needs
73
+ the engines and the timer and nothing that touches money.
74
+ - **The client.** Occasionally handed a pit; should see what they are being
75
+ billed and the time behind it, and touch nothing.
76
+
77
+ ## Requirements
78
+
79
+ - R1 [P0] `/timer on|off` tracks time to a local ledger, with no dependency on
80
+ a client, a rate or a gateway.
81
+ - R2 [P0] A timer records how many agents were running, and `--agents auto`
82
+ reads that from the herd rather than asking.
83
+ - R3 [P0] `/client create` accepts contact details as they arrive — a comma
84
+ form for what is pasted, `--a.b` dotted flags for anything else — with no
85
+ fixed field list.
86
+ - R4 [P0] `/rate set <who> <spec>` parses the contract sentence, including
87
+ period, unit, `upto:N` cap and `min:N` floor, in any order after the price.
88
+ - R5 [P0] `/billing <client>` previews without writing; `--mark` claims the
89
+ time exactly once; `--send` composes the gateway command and only `--yes`
90
+ runs it.
91
+ - R6 [P0] An invoice refuses to settle when there is no client payee and no
92
+ wallet rail.
93
+ - R7 [P1] `/payments` connects a rail — CLI (CoinPay, Stripe), OAuth (PayPal,
94
+ Coinbase) or a bare wallet — and stores no secret, only a vault reference.
95
+ - R8 [P1] `/team` records people, roles and grants; `MOSHCODE_MEMBER` makes the
96
+ pit act as one of them, and the gate refuses commands they have no grant for.
97
+ - R9 [P1] A permission is written however it is said — `tools:coinpay`,
98
+ `tools/coinpay`, `allow(tools/coinpay)` — and means the same thing.
99
+ - R10 [P2] `/business`, `/merchant` and `/customer` are the same command as
100
+ `/client`; `/rates`, `/teams` and `/invoice` likewise.
101
+
102
+ ## UX Notes
103
+
104
+ The five words are `/timer`, `/client`, `/rate`, `/billing`, `/payments`, plus
105
+ `/team` for who may run them. Each is useful alone, which is the test each one
106
+ had to pass: the timer with no rate, the rate with no gateway, the client with
107
+ no invoice.
108
+
109
+ Aliases are not synonyms in general English — a merchant is not a customer — but
110
+ they are the same party in every conversation this is for, and the word somebody
111
+ reaches for depends on which product taught it to them. Three doors, one room.
112
+
113
+ The gate refuses by naming the permission and the command that would grant it,
114
+ because the person who hits it is not the person who can fix it, and "ask an
115
+ owner for `/team grant acme preshy tools:coinpay`" is a message they can paste.
116
+
117
+ ## Open Questions
118
+
119
+ - Should the business record sync to app.moshcode.sh the way settings do (PRD
120
+ 0010)? An agency's client list on one laptop is the same problem 0010 solved
121
+ for aliases — but a client list is a different kind of data, and the answer
122
+ may be that it belongs on the account rather than in a sync.
123
+ - Should `/team` grants travel with `/load`, so a machine handed to a contractor
124
+ is configured by logging into it? That is the version where this stops being a
125
+ personal record and starts being an operator tool.
126
+ - Beyond CoinPay, `--send` prints numbers rather than composing a command line.
127
+ Stripe's CLI is the obvious next one to teach it.
128
+ - Member rates are recorded but not yet used: cost-per-person alongside
129
+ price-per-client is what turns an invoice into a margin.
@@ -0,0 +1,363 @@
1
+ // Tracked time × the rate they agreed to = the number you send them.
2
+ //
3
+ // This is the join, and it is the only file that touches all four of the other
4
+ // ones: the ledger from `/timer`, the rate from `/rate`, the address from
5
+ // `/client`, and the rail from `/payments`. It does the arithmetic nobody
6
+ // enjoys doing at the end of a month, and then it stops — moshcode composes an
7
+ // invoice, CoinPay (or Stripe, or a wallet) delivers it.
8
+ //
9
+ // Two rules the shape here exists to enforce:
10
+ //
11
+ // Nothing is billed twice. An entry carries `billed` and the invoice id that
12
+ // claimed it, and drafting is separate from claiming: `/billing acme` is a
13
+ // preview you can run all day, `--mark` is the one that writes.
14
+ //
15
+ // Nothing settles to an address nobody decided on. A client with no payee
16
+ // and no default wallet gets a refusal, not a best guess, because the failure
17
+ // mode of guessing is money arriving somewhere it cannot be recovered from.
18
+ import { spawnSync } from "node:child_process";
19
+
20
+ import { loadBusiness, loadTimers, newId, updateBusiness, updateTimers } from "./business-store.mjs";
21
+ import { clientLabel, parseFields, resolveClient } from "./clients.mjs";
22
+ import { GATEWAYS, defaultGateway, gatewayState } from "./payments.mjs";
23
+ import { chargeFor, describeRate, formatMoney, isDollarPegged, isFiat, rateFor } from "./rates.mjs";
24
+ import { humanDuration, selectEntries, windowFrom } from "./timer.mjs";
25
+ import { acid, ash, bone, err, info, ok, table, warn } from "./ui.mjs";
26
+
27
+ /** A line is a task; entries against the same task collapse into one. */
28
+ function lineKeyFor(entry) {
29
+ return entry.task || entry.note || "untracked";
30
+ }
31
+
32
+ /**
33
+ * Turn a window of tracked time into an invoice draft.
34
+ *
35
+ * Charged per entry and then summed, not summed and then charged: the agent
36
+ * count varies between entries, and an average would quietly bill a two-agent
37
+ * afternoon at the four-agent rate (or the other way round, which is worse for
38
+ * a different reason).
39
+ */
40
+ export function buildInvoice({ business, timers, clientId, since = null, until = null, all = false }) {
41
+ const client = business.clients?.[clientId] || null;
42
+ const rate = rateFor(business, clientId);
43
+ const entries = selectEntries(timers.entries || [], { client: clientId, since, until, unbilled: !all });
44
+
45
+ const lines = new Map();
46
+ const warnings = [];
47
+ let total = 0;
48
+ let seconds = 0;
49
+ const currency = rate?.currency || "USD";
50
+
51
+ for (const entry of entries) {
52
+ seconds += entry.seconds || 0;
53
+ const charge = chargeFor(entry, rate);
54
+ const key = lineKeyFor(entry);
55
+ const line = lines.get(key) || { what: key, seconds: 0, agents: 0, amount: 0, entries: [], flat: false };
56
+ line.seconds += entry.seconds || 0;
57
+ line.agents = Math.max(line.agents, entry.agents || 1);
58
+ line.entries.push(entry.id);
59
+ if (charge?.amount != null) { line.amount += charge.amount; total += charge.amount; }
60
+ if (charge?.flat) line.flat = true;
61
+ lines.set(key, line);
62
+ if (rate?.cap && (entry.agents || 1) > rate.cap) {
63
+ warnings.push(`${entry.id}: ${entry.agents} agents ran, ${rate.cap} billed (the cap)`);
64
+ }
65
+ }
66
+
67
+ // A flat project fee is earned once, however many entries sit under it.
68
+ if (rate?.per === "project" && entries.length) {
69
+ lines.set("project fee", { what: "project fee", seconds: 0, agents: 0, amount: rate.amount, entries: [], flat: true });
70
+ total += rate.amount;
71
+ }
72
+
73
+ return {
74
+ clientId,
75
+ client,
76
+ rate,
77
+ currency,
78
+ lines: [...lines.values()],
79
+ entries,
80
+ entryIds: entries.map((e) => e.id),
81
+ seconds,
82
+ total,
83
+ warnings,
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Where this client's money should land: their payee, else the wallet rail.
89
+ *
90
+ * Per-client because the answer genuinely differs — one client pays a business
91
+ * account, another pays a project wallet — and a single global address makes
92
+ * that impossible to express without a second tool.
93
+ */
94
+ export function settlementFor(business, clientId) {
95
+ const client = business.clients?.[clientId];
96
+ if (client?.payee?.address) return { ...client.payee, source: "client" };
97
+ const wallet = business.payments?.gateways?.wallet;
98
+ if (wallet?.address) return { chain: wallet.chain, address: wallet.address, source: "wallet" };
99
+ return null;
100
+ }
101
+
102
+ /**
103
+ * The CoinPay command line that turns a draft into a real invoice:
104
+ * `{ ok, args }`, or `{ ok: false, reason }`.
105
+ *
106
+ * Built rather than run: the amount, the currency and the address are decisions
107
+ * with consequences, and the last thing between them and a stranger's wallet
108
+ * should be a line a person can read. `--yes` is what runs it, which is the
109
+ * same convention CoinPay's own CLI uses for its irreversible verbs.
110
+ *
111
+ * The currency split is CoinPay's, not ours: `--currency` is a three-letter
112
+ * fiat code and the settlement ticker travels in `--crypto-currency`. That
113
+ * expresses a dollar-priced invoice settled in crypto exactly, and a
114
+ * USDC-priced one honestly, because USDC is a dollar. It cannot express a rate
115
+ * priced in SOL or BTC — the fiat amount would be a number nobody computed —
116
+ * so that case is refused with its reason rather than converted by guesswork.
117
+ */
118
+ export function coinpayArgs(invoice, { payee = null, dueDate = null } = {}) {
119
+ const currency = invoice.currency;
120
+ if (!isFiat(currency) && !isDollarPegged(currency)) {
121
+ return {
122
+ ok: false,
123
+ reason: `this rate is priced in ${currency}, and a CoinPay invoice carries a fiat amount — `
124
+ + `price it in a currency (--prefer ${currency} keeps the settlement) or send it yourself`,
125
+ };
126
+ }
127
+ const priced = isFiat(currency) ? currency : "USD";
128
+ const crypto = isFiat(currency)
129
+ ? (invoice.rate?.prefer || []).find((c) => !isFiat(c)) || null
130
+ : currency;
131
+ const args = [
132
+ "invoice", "create",
133
+ "--amount", invoice.total.toFixed(2),
134
+ "--currency", priced,
135
+ ];
136
+ if (crypto) args.push("--crypto-currency", crypto);
137
+ if (dueDate) args.push("--due-date", dueDate);
138
+ if (payee?.address) args.push("--merchant-wallet-address", payee.address);
139
+ args.push("--notes", invoiceNote(invoice));
140
+ return { ok: true, args };
141
+ }
142
+
143
+ function invoiceNote(invoice) {
144
+ const who = invoice.client?.name || invoice.clientId || "work";
145
+ const lines = invoice.lines.filter((l) => l.what !== "project fee").map((l) => l.what);
146
+ const summary = lines.length ? `: ${lines.slice(0, 3).join(", ")}${lines.length > 3 ? `, +${lines.length - 3} more` : ""}` : "";
147
+ return `${who} — ${humanDuration(invoice.seconds)}${summary}`;
148
+ }
149
+
150
+ const USAGE = [
151
+ "usage: /billing <client> [--today|--week|--month|--since <date>] [--all] [--json]",
152
+ " /billing <client> --mark claim the time and record an invoice",
153
+ " /billing <client> --send [--yes] hand it to the connected gateway",
154
+ " /billing list · /billing show <id> · /billing void <id>",
155
+ ];
156
+
157
+ export function billingCommand(argv = [], { write = console.log, run = spawnSync } = {}) {
158
+ const verb = String(argv[0] ?? "").toLowerCase();
159
+ if (["list", "ls", ""].includes(verb) && argv.length <= 1) return listInvoices(argv.includes("--json"), write);
160
+ if (["show", "get"].includes(verb)) return showInvoice(argv[1], argv.includes("--json"), write);
161
+ if (["void", "cancel"].includes(verb)) return voidInvoice(argv[1], write);
162
+ return draftInvoice(argv, write, run);
163
+ }
164
+
165
+ function draftInvoice(argv, write, run) {
166
+ const { fields, rest } = parseFields(argv);
167
+ const business = loadBusiness();
168
+ const timers = loadTimers();
169
+
170
+ const found = resolveClient(business, rest[0]);
171
+ if (!found.ok) {
172
+ if (found.reason === "ambiguous") { write(err(`${JSON.stringify(rest[0])} matches ${found.matches.join(", ")} — say which`)); return 1; }
173
+ write(err(`no client ${JSON.stringify(rest[0] ?? "")} — ${acid("/client list")}`));
174
+ USAGE.forEach(write);
175
+ return 1;
176
+ }
177
+
178
+ const invoice = buildInvoice({
179
+ business,
180
+ timers,
181
+ clientId: found.id,
182
+ since: windowFrom(fields),
183
+ all: Boolean(fields.all),
184
+ });
185
+
186
+ if (fields.json) { write(JSON.stringify(invoice, null, 2)); return 0; }
187
+
188
+ if (!invoice.entries.length) {
189
+ write(info(`nothing unbilled for ${bone(found.id)}${fields.all ? "" : " — --all to include time already billed"}`));
190
+ return 0;
191
+ }
192
+ if (!invoice.rate) {
193
+ write(err(`no rate for ${bone(found.id)} — ${acid(`/rate set ${found.id} $100/hour/agent/upto:4`)}`));
194
+ return 1;
195
+ }
196
+
197
+ write(` ${clientLabel(found.id, invoice.client)} ${ash("·")} ${ash(describeRate(invoice.rate))}`);
198
+ write(table(
199
+ invoice.lines.map((l) => [
200
+ l.what,
201
+ l.seconds ? humanDuration(l.seconds) : ash("—"),
202
+ l.agents > 1 ? `${l.agents}×` : "",
203
+ acid(formatMoney(l.amount, invoice.currency)),
204
+ ]),
205
+ { columns: ["what", "time", "agents", "amount"], indent: 2 },
206
+ ));
207
+ write(` ${ash("total")} ${bone(humanDuration(invoice.seconds))} ${ash("→")} ${acid(formatMoney(invoice.total, invoice.currency))}`);
208
+ for (const note of invoice.warnings) write(` ${ash(note)}`);
209
+
210
+ const settlement = settlementFor(business, found.id);
211
+ if (!settlement) {
212
+ write(warn("no payee for this client and no wallet rail — nothing to settle to"));
213
+ write(` ${acid(`/client payee ${found.id} solana:<address>`)} ${ash("or")} ${acid("/payments connect wallet --chain solana --address <addr>")}`);
214
+ } else {
215
+ write(` ${ash("settles to")} ${acid(`${settlement.chain}:${settlement.address}`)} ${ash(`(${settlement.source})`)}`);
216
+ }
217
+
218
+ if (!fields.mark && !fields.send) {
219
+ write(` ${ash("this is a preview —")} ${acid(`/billing ${found.id} --mark`)} ${ash("claims the time,")} ${acid("--send")} ${ash("hands it to the gateway")}`);
220
+ return 0;
221
+ }
222
+
223
+ if (!settlement) return 1;
224
+
225
+ const record = commitInvoice(invoice, settlement);
226
+ const claimed = record.entryIds.length;
227
+ write(ok(`invoice ${bone(record.id)} — ${acid(formatMoney(record.total, record.currency))} ${ash(`(${claimed} ${claimed === 1 ? "entry" : "entries"} claimed)`)}`));
228
+
229
+ if (!fields.send) return 0;
230
+ return handOff(record, invoice, business, fields, write, run);
231
+ }
232
+
233
+ /** Write the invoice and mark its entries, in that order. */
234
+ function commitInvoice(invoice, settlement) {
235
+ const id = newId("inv-");
236
+ const record = {
237
+ id,
238
+ client: invoice.clientId,
239
+ createdAt: new Date().toISOString(),
240
+ currency: invoice.currency,
241
+ total: invoice.total,
242
+ seconds: invoice.seconds,
243
+ lines: invoice.lines,
244
+ entryIds: invoice.entryIds,
245
+ rate: invoice.rate,
246
+ settlement,
247
+ status: "draft",
248
+ gateway: null,
249
+ gatewayRef: null,
250
+ };
251
+ updateBusiness((data) => { data.invoices[id] = record; });
252
+ updateTimers((data) => {
253
+ for (const entry of data.entries) {
254
+ if (record.entryIds.includes(entry.id)) { entry.billed = true; entry.invoice = id; }
255
+ }
256
+ });
257
+ return record;
258
+ }
259
+
260
+ function handOff(record, invoice, business, fields, write, run) {
261
+ const key = fields.gateway && fields.gateway !== true ? String(fields.gateway).toLowerCase() : defaultGateway(business);
262
+ if (!key || !GATEWAYS[key]) {
263
+ write(warn("no payment rail chosen — the invoice is recorded, but nothing was sent"));
264
+ write(` ${acid("/payments connect coinpay")}`);
265
+ return 1;
266
+ }
267
+ if (key !== "coinpay") {
268
+ // Every other rail is a passthrough moshcode has not been taught to speak.
269
+ // Saying so is better than composing a command line from guesswork.
270
+ write(info(`${bone(key)} is connected, but moshcode only composes CoinPay invoices`));
271
+ write(` ${ash("the numbers are above, and")} ${acid(`/billing show ${record.id}`)} ${ash("has them again whenever you need them")}`);
272
+ return 0;
273
+ }
274
+
275
+ const state = gatewayState("coinpay", business);
276
+ if (!state.installed) { write(err(`coinpay is not on PATH — ${acid("/install coinpay")}`)); return 1; }
277
+
278
+ const composed = coinpayArgs({ ...invoice, total: record.total }, { payee: record.settlement, dueDate: fields.due && fields.due !== true ? String(fields.due) : null });
279
+ if (!composed.ok) {
280
+ write(warn(composed.reason));
281
+ write(` ${ash(`invoice ${record.id} is recorded either way —`)} ${acid(`/billing show ${record.id}`)}`);
282
+ return 1;
283
+ }
284
+ const { args } = composed;
285
+ const printable = `coinpay ${args.map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(" ")}`;
286
+ write(` ${ash(printable)}`);
287
+ if (!fields.yes) {
288
+ write(` ${ash("read it, then")} ${acid(`/billing ${record.client} --send --yes`)} ${ash("to run it")}`);
289
+ return 0;
290
+ }
291
+
292
+ const result = run("coinpay", args, { stdio: "inherit" });
293
+ if (result?.error) { write(err(String(result.error.message || result.error))); return 1; }
294
+ if (result?.status) { write(err(`coinpay exited ${result.status} — invoice ${record.id} is still a local draft`)); return result.status; }
295
+ updateBusiness((data) => {
296
+ data.invoices[record.id].gateway = "coinpay";
297
+ data.invoices[record.id].status = "handed-off";
298
+ });
299
+ write(ok(`handed to CoinPay — ${acid("coinpay invoice send <id>")} ${ash("emails it to the client")}`));
300
+ return 0;
301
+ }
302
+
303
+ function listInvoices(json, write) {
304
+ const { invoices } = loadBusiness();
305
+ const ids = Object.keys(invoices).sort();
306
+ if (json) { write(JSON.stringify(invoices, null, 2)); return 0; }
307
+ if (!ids.length) { write(info("no invoices yet.")); write(` ${acid("/billing <client> --mark")}`); return 0; }
308
+ write(table(
309
+ ids.map((id) => {
310
+ const inv = invoices[id];
311
+ return [
312
+ bone(id),
313
+ inv.client || "",
314
+ ash(String(inv.createdAt || "").slice(0, 10)),
315
+ humanDuration(inv.seconds),
316
+ acid(formatMoney(inv.total, inv.currency)),
317
+ ash(inv.status || "draft"),
318
+ ];
319
+ }),
320
+ { columns: ["id", "client", "date", "time", "amount", "status"], indent: 2 },
321
+ ));
322
+ return 0;
323
+ }
324
+
325
+ function showInvoice(id, json, write) {
326
+ const { invoices } = loadBusiness();
327
+ const invoice = invoices[String(id ?? "")];
328
+ if (!invoice) { write(err(`no invoice ${JSON.stringify(id ?? "")} — ${acid("/billing list")}`)); return 1; }
329
+ if (json) { write(JSON.stringify(invoice, null, 2)); return 0; }
330
+ write(` ${bone(invoice.id)} ${ash(invoice.client || "")} ${ash(String(invoice.createdAt).slice(0, 10))}`);
331
+ write(table(
332
+ invoice.lines.map((l) => [l.what, l.seconds ? humanDuration(l.seconds) : ash("—"), acid(formatMoney(l.amount, invoice.currency))]),
333
+ { columns: ["what", "time", "amount"], indent: 2 },
334
+ ));
335
+ write(` ${ash("total")} ${acid(formatMoney(invoice.total, invoice.currency))} ${ash(`· ${invoice.status}`)}`);
336
+ if (invoice.settlement) write(` ${ash("settles to")} ${acid(`${invoice.settlement.chain}:${invoice.settlement.address}`)}`);
337
+ return 0;
338
+ }
339
+
340
+ /**
341
+ * Undo the claim, not the invoice.
342
+ *
343
+ * The entries go back to unbilled so they can be re-drafted; the invoice record
344
+ * stays and is marked void. Deleting it would erase the fact that a number was
345
+ * once quoted, which is exactly the fact somebody comes looking for.
346
+ */
347
+ function voidInvoice(id, write) {
348
+ const key = String(id ?? "");
349
+ const { invoices } = loadBusiness();
350
+ const invoice = invoices[key];
351
+ if (!invoice) { write(err(`no invoice ${JSON.stringify(key)}`)); return 1; }
352
+ if (invoice.status === "void") { write(info(`${key} is already void`)); return 0; }
353
+ updateBusiness((data) => { data.invoices[key].status = "void"; data.invoices[key].voidedAt = new Date().toISOString(); });
354
+ updateTimers((data) => {
355
+ for (const entry of data.entries) {
356
+ if (entry.invoice === key) { entry.billed = false; entry.invoice = null; }
357
+ }
358
+ });
359
+ write(ok(`${bone(key)} void — ${invoice.entryIds.length} entries are billable again`));
360
+ return 0;
361
+ }
362
+
363
+ export { USAGE as BILLING_USAGE };