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 +88 -0
- package/bin/moshcode.mjs +34 -0
- package/package.json +1 -1
- package/prd/0012-billing-baked-into-the-agent-cli.md +129 -0
- package/src/billing.mjs +363 -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/rates.mjs +368 -0
- package/src/teams.mjs +459 -0
- package/src/timer.mjs +354 -0
- package/src/tui.mjs +54 -0
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
|
@@ -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 };
|