Skip to content

Commit b40c0e4

Browse files
fix: harden agent multi-company workflows
1 parent abf4de7 commit b40c0e4

10 files changed

Lines changed: 351 additions & 45 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "invoice-cli"
3-
version = "0.5.9"
3+
version = "0.5.10"
44
edition = "2021"
55
description = "Beautiful invoices from the CLI — international, stateful, agent-friendly"
66
license = "MIT"

README.md

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ interface built for agents.
2424

2525
- **Multi-issuer first-class.** Run several companies (SG Pte. Ltd., UK Ltd.,
2626
US LLC, …) from one binary. Each issuer has its own jurisdiction, tax
27-
profile, default template, numbering series, and logo.
27+
profile, default template, numbering series, and logo. New issuers default
28+
to `{issuer}-{year}-{seq:04}` numbering so invoice IDs stay globally
29+
addressable for agents even when several companies share one database.
2830
- **Per-client defaults.** Pin a default issuer and/or template per client —
2931
then just `invoice invoices new --client meridian --item design` and the
3032
right entity + branding lights up automatically.
@@ -43,7 +45,8 @@ interface built for agents.
4345
the header at the appropriate size for its design language.
4446
- **Credit notes.** Issue against any existing invoice with `credit-note
4547
--full` (full reversal) or `--item ...` (specific refund lines). Independent
46-
`CN-YYYY-NNNN` numbering series so credit notes don't collide with invoices.
48+
`CN-{issuer}-YYYY-NNNN` numbering series so credit notes don't collide with
49+
invoices.
4750
- **Draft-only editing.** Amend a draft's metadata with `invoices edit` or
4851
its line items with `invoices items add|remove|edit`. Once issued, invoices
4952
are immutable — the correct path for corrections is a credit note, which
@@ -115,15 +118,15 @@ invoice products add design \
115118
invoice invoices new --client meridian --item design --due 30d
116119
117120
# 5. Render + open
118-
invoice invoices render 2026-0001 --open
121+
invoice invoices render acme-2026-0001 --open
119122
120123
# 6. Later: mark paid, clone for next month
121-
invoice invoices mark 2026-0001 paid
122-
invoice invoices duplicate 2026-0001
124+
invoice invoices mark acme-2026-0001 paid
125+
invoice invoices duplicate acme-2026-0001
123126
124127
# 7. Need a refund? Credit note against the original. Positive refund
125128
# specs are stored as credits automatically:
126-
invoice invoices credit-note 2026-0001 --item "Refund:1:500" --notes "Goodwill credit"
129+
invoice invoices credit-note acme-2026-0001 --item "Refund:1:500" --notes "Goodwill credit"
127130
128131
# 8. Month-end accountant handoff
129132
invoice invoices export --from 2026-01-01 --to 2026-03-31 --format csv --out q1.csv
@@ -151,7 +154,7 @@ invoice invoices export --from 2026-01-01 --to 2026-03-31 --format csv --out q1.
151154
| `invoices export --from X --to Y --format csv\|json` | Accountant handoff |
152155
| `invoices delete <number> [--force]` | Delete an invoice (`--force` for non-draft) |
153156
| `template list\|preview <name>` | Inspect available templates |
154-
| `doctor` | Diagnose typst install, DB, templates |
157+
| `doctor` | Diagnose typst install, DB, templates, default issuer, numbering |
155158
| `agent-info` | Full JSON capability manifest |
156159
| `skill install` | Install embedded Claude/Codex/Gemini skill |
157160
| `update [--check]` | Self-update via brew or cargo |
@@ -199,6 +202,8 @@ contract:
199202

200203
- Every command emits a `{version, status, data|error}` envelope when piped.
201204
- `invoice agent-info` returns a full capability + exit-code manifest.
205+
- `invoice doctor --json` reports setup, default issuer validity, and risky
206+
multi-company numbering formats.
202207
- `invoice skill install` drops a ready-to-use skill file into
203208
`~/.claude/skills/invoice-cli/SKILL.md` (and the Codex/Gemini equivalents).
204209

@@ -209,6 +214,11 @@ USER: "Bill Meridian for last month's design work"
209214
AGENT: invoice invoices duplicate $(invoice invoices list --json | jq -r '.data[0].number')
210215
```
211216

217+
Agents should use invoice numbers returned by JSON responses, not predict the
218+
next sequence. The CLI keeps numbers globally unique; if legacy issuers share a
219+
plain `{year}-{seq:04}` format, generation auto-prefixes the issuer slug on
220+
collision and `doctor` warns so you can clean up the formats.
221+
212222
## Architecture
213223

214224
- **Rust** binary via `cargo` / single-binary distribution.

src/cli.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ pub enum IssuerCmd {
104104
/// etc.
105105
#[arg(long)]
106106
notes: Option<String>,
107+
/// Invoice number format. Tokens: {issuer}, {year}, {seq}, {seq:04}.
108+
/// Default includes {issuer} so multiple companies cannot collide.
109+
#[arg(long)]
110+
number_format: Option<String>,
107111
},
108112
/// Edit an existing issuer — pass only the fields you want to change
109113
Edit {
@@ -142,6 +146,8 @@ pub enum IssuerCmd {
142146
currency: Option<String>,
143147
#[arg(long)]
144148
symbol: Option<String>,
149+
/// Invoice number format. Tokens: {issuer}, {year}, {seq}, {seq:04}.
150+
/// Use a unique prefix per issuer for globally addressable invoice ids.
145151
#[arg(long)]
146152
number_format: Option<String>,
147153
#[arg(long)]

src/commands/agent_info.rs

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ pub fn run(_ctx: Ctx) -> Result<()> {
2626
// Built outside the json! macro to avoid hitting the proc-macro recursion
2727
// limit as the command surface grows.
2828
let commands_list: &[(&str, &str)] = &[
29-
("issuer add <slug> --name X --jurisdiction sg|uk|us|eu --address ... [--logo PATH]", "Register an issuer (billing entity). --logo points to a PNG/SVG/JPG rendered in template header"),
30-
("issuer edit <slug> [--name ... --template ... --jurisdiction ... --logo PATH etc]", "Update any subset of an issuer's fields (incl. logo path)"),
29+
("issuer add <slug> --name X --jurisdiction sg|uk|us|eu --address ... [--logo PATH --number-format F]", "Register an issuer (billing entity). New issuers default to {issuer}-{year}-{seq:04} so invoice numbers are globally addressable"),
30+
("issuer edit <slug> [--name ... --template ... --jurisdiction ... --number-format ... --logo PATH etc]", "Update any subset of an issuer's fields (incl. logo path and numbering format)"),
3131
("issuer set-template <slug> <template>", "Shorthand: change an issuer's default template"),
3232
("issuer list | ls", "List issuers"),
3333
("issuer show <slug> | get", "Show issuer details"),
@@ -101,7 +101,50 @@ pub fn run(_ctx: Ctx) -> Result<()> {
101101
"database": database,
102102
"templates": ["helvetica-nera", "tiefletter-gold", "monoline", "vienna", "boutique"],
103103
"tax_profiles": profiles,
104-
"item_spec": "product-slug[:qty] OR description:qty:price[:rate]"
104+
"item_spec": "product-slug[:qty] OR description:qty:price[:rate]",
105+
"config_keys": {
106+
"default_issuer": "Issuer slug used by invoices new when --as is omitted and the client has no default issuer. `invoice config set default_issuer unset` clears it.",
107+
"self_update": "Reserved shared setting for updater behavior across the suite."
108+
},
109+
"first_run": [
110+
"invoice doctor --json",
111+
"invoice issuer add <slug> --name <display-name> --jurisdiction sg|uk|us|eu|custom --address \"line1\\nline2\"",
112+
"invoice config set default_issuer <issuer-slug>",
113+
"invoice clients add <slug> --name <client-name> --address \"line1\\nline2\" --default-issuer <issuer-slug>",
114+
"invoice invoices new --client <client-slug> --item \"Description:1:100:20\""
115+
],
116+
"examples": [
117+
{
118+
"goal": "Create an invoice using client/default issuer routing",
119+
"command": "invoice invoices new --client meridian --item \"Consulting:1:1200:20\" --json"
120+
},
121+
{
122+
"goal": "Create an invoice for a specific company",
123+
"command": "invoice invoices new --as paperfoot --client meridian --item design:1 --json"
124+
},
125+
{
126+
"goal": "Render an invoice PDF",
127+
"command": "invoice invoices render paperfoot-2026-0001 --json"
128+
},
129+
{
130+
"goal": "Issue a full credit note",
131+
"command": "invoice invoices credit-note paperfoot-2026-0001 --full --json"
132+
}
133+
],
134+
"multi_company_model": {
135+
"issuer": "The company/person billing as. Each issuer owns tax profile, logo, bank details, default notes/output dir, and its own sequence counter.",
136+
"client": "The customer. A client may pin default_issuer and default_template.",
137+
"invoice_numbering": "Invoice numbers are globally addressable by the CLI. New issuers default to {issuer}-{year}-{seq:04}; legacy colliding formats are auto-prefixed with the issuer slug on collision.",
138+
"recommended_default": "Set config.default_issuer for single-company workflows; use client.default_issuer or explicit --as for multi-company workflows."
139+
},
140+
"guardrails": [
141+
"Run doctor before first use and after changing config.",
142+
"Prefer --json for agents; stdout is data and stderr is diagnostics.",
143+
"Use globally unique issuer number formats, ideally {issuer}-{year}-{seq:04}.",
144+
"Do not delete issued invoices; mark void or create a credit note.",
145+
"Use invoice numbers returned by JSON responses rather than guessing the next sequence.",
146+
"Pin client default issuers when the same customer is always billed by the same company."
147+
]
105148
});
106149

107150
print_raw(&manifest);

src/commands/config.rs

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ pub fn run(cmd: ConfigCmd, ctx: Ctx) -> Result<()> {
1616
Ok(())
1717
}
1818
ConfigCmd::Set { key, value } => {
19+
let remove_key = matches!(
20+
value.to_ascii_lowercase().as_str(),
21+
"unset" | "none" | "null" | ""
22+
);
23+
validate_config_value(&key, &value, remove_key)?;
24+
1925
// Minimal implementation: set-and-save via toml::Value merge.
2026
let path = config::config_path()?;
2127
let existing = if path.exists() {
@@ -26,21 +32,43 @@ pub fn run(cmd: ConfigCmd, ctx: Ctx) -> Result<()> {
2632
let mut doc: toml::Value =
2733
toml::from_str(&existing).unwrap_or(toml::Value::Table(Default::default()));
2834
if let toml::Value::Table(ref mut t) = doc {
29-
t.insert(key.clone(), parse_value(&value));
35+
if remove_key {
36+
t.remove(&key);
37+
} else {
38+
t.insert(key.clone(), parse_value(&value));
39+
}
3040
} else {
3141
return Err(AppError::Config("config root is not a table".into()));
3242
}
3343
std::fs::write(&path, toml::to_string_pretty(&doc).unwrap())?;
3444
print_success(
3545
ctx,
36-
&serde_json::json!({"key": key, "value": value}),
37-
|_| println!("set {} = {}", key, value),
46+
&serde_json::json!({"key": key, "value": if remove_key { serde_json::Value::Null } else { serde_json::Value::String(value.clone()) }}),
47+
|_| {
48+
if remove_key {
49+
println!("unset {key}");
50+
} else {
51+
println!("set {key} = {value}");
52+
}
53+
},
3854
);
3955
Ok(())
4056
}
4157
}
4258
}
4359

60+
fn validate_config_value(key: &str, value: &str, remove_key: bool) -> Result<()> {
61+
if key == "default_issuer" && !remove_key {
62+
let conn = crate::db::open()?;
63+
crate::db::issuer_by_slug(&conn, value).map_err(|_| {
64+
AppError::InvalidInput(format!(
65+
"unknown issuer '{value}' for config.default_issuer. Add it first or run `invoice issuer list`."
66+
))
67+
})?;
68+
}
69+
Ok(())
70+
}
71+
4472
fn parse_value(v: &str) -> toml::Value {
4573
if v == "true" {
4674
toml::Value::Boolean(true)

src/commands/doctor.rs

Lines changed: 88 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,16 @@ pub fn run(ctx: Ctx) -> Result<()> {
7070
message: format!("{} available: {}", templates.len(), templates.join(", ")),
7171
});
7272

73-
// db
73+
// db + suite-level invariants
7474
match crate::db::open() {
75-
Ok(_) => checks.push(Check {
76-
name: "database".into(),
77-
status: "pass",
78-
message: format!("{} ok", config::db_path()?.display()),
79-
}),
75+
Ok(conn) => {
76+
checks.push(Check {
77+
name: "database".into(),
78+
status: "pass",
79+
message: format!("{} ok", config::db_path()?.display()),
80+
});
81+
add_suite_checks(&mut checks, &conn)?;
82+
}
8083
Err(e) => checks.push(Check {
8184
name: "database".into(),
8285
status: "fail",
@@ -113,3 +116,82 @@ pub fn run(ctx: Ctx) -> Result<()> {
113116
}
114117
Ok(())
115118
}
119+
120+
fn add_suite_checks(checks: &mut Vec<Check>, conn: &rusqlite::Connection) -> Result<()> {
121+
let issuers = crate::db::issuer_list(conn)?;
122+
if issuers.is_empty() {
123+
checks.push(Check {
124+
name: "issuers".into(),
125+
status: "warn",
126+
message: "no issuers configured. First run: invoice issuer add <slug> --name ... --address ...".into(),
127+
});
128+
} else {
129+
checks.push(Check {
130+
name: "issuers".into(),
131+
status: "pass",
132+
message: format!("{} configured", issuers.len()),
133+
});
134+
}
135+
136+
let cfg = config::load()?;
137+
match cfg.default_issuer.as_deref() {
138+
Some(slug) => match crate::db::issuer_by_slug(conn, slug) {
139+
Ok(_) => checks.push(Check {
140+
name: "default-issuer".into(),
141+
status: "pass",
142+
message: format!("config.default_issuer = {slug}"),
143+
}),
144+
Err(_) => checks.push(Check {
145+
name: "default-issuer".into(),
146+
status: "fail",
147+
message: format!(
148+
"config.default_issuer points to missing issuer '{slug}'. Run: invoice config set default_issuer unset"
149+
),
150+
}),
151+
},
152+
None if issuers.is_empty() => checks.push(Check {
153+
name: "default-issuer".into(),
154+
status: "warn",
155+
message: "not set yet because no issuers exist".into(),
156+
}),
157+
None => checks.push(Check {
158+
name: "default-issuer".into(),
159+
status: "warn",
160+
message: "not set. Agents must pass --as or use clients with default issuers.".into(),
161+
}),
162+
}
163+
164+
if issuers.len() > 1 {
165+
use std::collections::BTreeMap;
166+
let mut by_format: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
167+
for issuer in &issuers {
168+
by_format
169+
.entry(issuer.number_format.as_str())
170+
.or_default()
171+
.push(issuer.slug.as_str());
172+
}
173+
let risky: Vec<String> = by_format
174+
.into_iter()
175+
.filter(|(format, slugs)| slugs.len() > 1 && !format.contains("{issuer}"))
176+
.map(|(format, slugs)| format!("{} share '{}'", slugs.join(", "), format))
177+
.collect();
178+
if risky.is_empty() {
179+
checks.push(Check {
180+
name: "numbering".into(),
181+
status: "pass",
182+
message: "multi-company number formats are distinct or include {issuer}".into(),
183+
});
184+
} else {
185+
checks.push(Check {
186+
name: "numbering".into(),
187+
status: "warn",
188+
message: format!(
189+
"{}. Invoice numbers are globally addressable; use --number-format '{{issuer}}-{{year}}-{{seq:04}}' for each issuer.",
190+
risky.join("; ")
191+
),
192+
});
193+
}
194+
}
195+
196+
Ok(())
197+
}

src/commands/issuers.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ pub fn run(cmd: IssuerCmd, ctx: Ctx) -> Result<()> {
2424
logo,
2525
output_dir,
2626
notes,
27+
number_format,
2728
} => {
2829
let jur = Jurisdiction::from_str(&jurisdiction).ok_or_else(|| {
2930
AppError::InvalidInput(format!(
@@ -52,7 +53,7 @@ pub fn run(cmd: IssuerCmd, ctx: Ctx) -> Result<()> {
5253
default_template: template,
5354
currency: Some(profile.currency.to_string()),
5455
symbol: Some(profile.symbol.to_string()),
55-
number_format: "{year}-{seq:04}".into(),
56+
number_format: number_format.unwrap_or_else(|| "{issuer}-{year}-{seq:04}".into()),
5657
logo_path: logo,
5758
default_output_dir: output_dir,
5859
default_notes: notes,

src/commands/skill.rs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ invoice clients add meridian --name "Meridian & Co." --country US --address "...
2424
--default-issuer acme --default-template boutique
2525
invoice products add design --description "Design engagement" --unit project --price 8400 --currency SGD --tax-rate 9
2626
invoice invoices new --client meridian --item design --due 30d # no --as needed: uses client default
27-
invoice invoices render 2026-0001 --open # uses client.default_template
28-
invoice invoices mark 2026-0001 paid # auto-stamps paid_at
29-
invoice invoices duplicate 2026-0001 # clone for next month's billing
27+
invoice invoices render acme-2026-0001 --open # uses client.default_template
28+
invoice invoices mark acme-2026-0001 paid # auto-stamps paid_at
29+
invoice invoices duplicate acme-2026-0001 # clone for next month's billing
3030
```
3131
3232
### Editing existing records
@@ -52,9 +52,9 @@ invoice issuer edit acme --logo ~/Pictures/acme.png
5252
DRAFT invoices are mutable; once `issued`/`paid`/`void` they're immutable — use a credit note.
5353
5454
```
55-
invoice invoices edit 2026-0001 --notes "Net 14 — early-payment 2% discount"
56-
invoice invoices items add 2026-0001 "Extra fee:1:500"
57-
invoice invoices credit-note 2026-0001 --item "Refund:1:500" --notes "Goodwill credit"
55+
invoice invoices edit acme-2026-0001 --notes "Net 14 — early-payment 2% discount"
56+
invoice invoices items add acme-2026-0001 "Extra fee:1:500"
57+
invoice invoices credit-note acme-2026-0001 --item "Refund:1:500" --notes "Goodwill credit"
5858
```
5959
6060
### Aging & export
@@ -75,10 +75,12 @@ invoice invoices new --client meridian --item design --discount-rate 10
7575
### Tips
7676
7777
- Run `invoice agent-info` for the full JSON capability manifest.
78-
- Run `invoice doctor` to verify typst is installed & DB is ready.
78+
- Run `invoice doctor --json` to verify typst, DB, default issuer, and multi-company numbering.
7979
- Item spec supports `product-slug[:qty]` OR `description:qty:price[:rate]`.
8080
- Template resolution at render: `--template` flag > client.default_template > issuer.default_template > `vienna`.
8181
- `--as` picks the issuer; omit it when the client has `default_issuer` pinned or `config.default_issuer` is set.
82+
- New issuers default to `{issuer}-{year}-{seq:04}` so invoice numbers are globally addressable; use `issuer edit --number-format` for per-company custom prefixes.
83+
- Use invoice numbers returned by JSON responses instead of predicting the next sequence.
8284
- `mark issued` / `mark paid` auto-stamp `issued_at` / `paid_at` (first transition only).
8385
- `invoices list` shows totals per invoice (computed with `rust_decimal`).
8486
- Every tax value is computed with `rust_decimal` — no float rounding.

0 commit comments

Comments
 (0)