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
|
@@ -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.
|
package/src/billing.mjs
ADDED
|
@@ -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 };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Where /timer and /billing actually run.
|
|
2
|
+
//
|
|
3
|
+
// Both started life inside moshcode (PRD 0012) and now also exist as their own
|
|
4
|
+
// cross-platform CLIs — @profullstack/timer and @profullstack/billing — because
|
|
5
|
+
// neither is a moshcode idea: tracking time and sending an invoice are things
|
|
6
|
+
// you want under any agentic CLI, and on Windows, where moshcode does not go.
|
|
7
|
+
//
|
|
8
|
+
// So the rule here is: when asked, moshcode conducts them the way /gh conducts
|
|
9
|
+
// gh. The two are NOT kept in sync, and that is the point of preferring the
|
|
10
|
+
// external one where it is wanted: a second copy of a billing model is a copy
|
|
11
|
+
// that drifts, and the published package is the one that gets the fixes.
|
|
12
|
+
//
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Why this is opt-in rather than "delegate whenever the CLI is on PATH", which
|
|
15
|
+
// is what it did when it first landed:
|
|
16
|
+
//
|
|
17
|
+
// Only half the business layer has somewhere to go. /timer and /billing have
|
|
18
|
+
// standalone equivalents; /client, /rate, /payments and /team do not — the
|
|
19
|
+
// rails and the permission model are moshcode's, and /client's freeform dotted
|
|
20
|
+
// fields have no shape in the package's typed client model, so moving it would
|
|
21
|
+
// lose data rather than relocate it.
|
|
22
|
+
//
|
|
23
|
+
// Delegating that half by default splits one person's records across two
|
|
24
|
+
// stores. /client and /rate keep writing ~/.moshcode/business.json while
|
|
25
|
+
// /billing reads ~/.profullstack/billing/ledger.json, so:
|
|
26
|
+
//
|
|
27
|
+
// /client create "Acme Inc" → written to moshcode
|
|
28
|
+
// /rate set acme-inc $100/hour → written to moshcode
|
|
29
|
+
// /billing acme-inc → "no client acme-inc"
|
|
30
|
+
//
|
|
31
|
+
// That is not a missing feature, it is somebody's invoice failing to exist. And
|
|
32
|
+
// it only happens on a machine that installed the CLIs, so CI — which has not —
|
|
33
|
+
// stays green while every developer box that took the install goes red.
|
|
34
|
+
//
|
|
35
|
+
// Hence: opt in, knowing that `billing import` is how the existing ledger comes
|
|
36
|
+
// across. When the whole layer has an outside home, this becomes the default.
|
|
37
|
+
import { isInstalled } from "./engines.mjs";
|
|
38
|
+
import { TOOLS, openTool } from "./tools.mjs";
|
|
39
|
+
|
|
40
|
+
/** Commands that have an external CLI, and the TOOLS key that owns it. */
|
|
41
|
+
export const DELEGATED = { timer: "timer", billing: "billing", invoice: "billing" };
|
|
42
|
+
|
|
43
|
+
/** Whether this machine has asked for the standalone CLIs to be used. */
|
|
44
|
+
export function externalEnabled() {
|
|
45
|
+
return /^(1|true|yes)$/i.test(String(process.env.MOSHCODE_EXTERNAL_BILLING || ""));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The external CLI for a command, if it is enabled and this machine has it. */
|
|
49
|
+
export function externalFor(cmd) {
|
|
50
|
+
if (!externalEnabled()) return null;
|
|
51
|
+
const key = DELEGATED[String(cmd || "").toLowerCase()];
|
|
52
|
+
if (!key) return null;
|
|
53
|
+
const tool = TOOLS[key];
|
|
54
|
+
if (!tool || !isInstalled(tool.bin, tool.binDirs)) return null;
|
|
55
|
+
return { key, tool };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Hand a command to its CLI, or report that there is nothing to hand it to.
|
|
60
|
+
*
|
|
61
|
+
* Returns `{ delegated: false }` when the CLI is absent so the caller can fall
|
|
62
|
+
* through to the built-in rather than failing — a missing optional tool is not
|
|
63
|
+
* an error, it is just the older path.
|
|
64
|
+
*/
|
|
65
|
+
export async function delegate(cmd, argv = [], opts = {}) {
|
|
66
|
+
const found = externalFor(cmd);
|
|
67
|
+
if (!found) return { delegated: false, code: 0 };
|
|
68
|
+
// openPassthrough resolves { ok, code, signal }, not a number. Assigning the
|
|
69
|
+
// object straight to process.exitCode throws ERR_INVALID_ARG_TYPE *after* the
|
|
70
|
+
// child has already printed its output, which reads as the tool crashing when
|
|
71
|
+
// in fact it succeeded.
|
|
72
|
+
const result = await openTool(found.tool, argv, opts);
|
|
73
|
+
return { delegated: true, code: exitCodeOf(result) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One number out of a passthrough result.
|
|
78
|
+
*
|
|
79
|
+
* A child killed by a signal reports `code: null`, and passing that on as 0
|
|
80
|
+
* would call an interrupted invoice run a success.
|
|
81
|
+
*/
|
|
82
|
+
export function exitCodeOf(result) {
|
|
83
|
+
if (typeof result === "number") return result;
|
|
84
|
+
if (!result || typeof result !== "object") return 0;
|
|
85
|
+
if (Number.isInteger(result.code)) return result.code;
|
|
86
|
+
if (result.signal) return 1;
|
|
87
|
+
return result.ok === false ? 1 : 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The one-line nudge shown after the built-in runs.
|
|
92
|
+
*
|
|
93
|
+
* Written to stderr, and only when the CLI is actually installed and simply not
|
|
94
|
+
* switched on, so it never lands in the middle of `--json` output and never
|
|
95
|
+
* advertises a tool that is not there. Someone who has installed the package is
|
|
96
|
+
* the only person for whom the variable is worth mentioning.
|
|
97
|
+
*/
|
|
98
|
+
export function installHint(cmd) {
|
|
99
|
+
const key = DELEGATED[String(cmd || "").toLowerCase()];
|
|
100
|
+
if (!key || externalEnabled()) return null;
|
|
101
|
+
const tool = TOOLS[key];
|
|
102
|
+
if (!tool || !isInstalled(tool.bin, tool.binDirs)) return null;
|
|
103
|
+
return `tip: @profullstack/${key} is installed — set MOSHCODE_EXTERNAL_BILLING=1 to use it`
|
|
104
|
+
+ " (move your records first with: billing import)";
|
|
105
|
+
}
|