Skip to main content

invoice_cli/
cli.rs

1use clap::{Parser, Subcommand};
2
3#[derive(Parser, Debug)]
4#[command(name = "invoice", version, about = "Beautiful invoices from the CLI")]
5pub struct Cli {
6    /// Emit JSON envelope on stdout (auto-detected when piped)
7    #[arg(long, global = true)]
8    pub json: bool,
9    /// Suppress human output
10    #[arg(long, global = true)]
11    pub quiet: bool,
12    #[command(subcommand)]
13    pub command: Commands,
14}
15
16#[derive(Subcommand, Debug)]
17pub enum Commands {
18    /// Manage issuers (the companies you invoice AS — supports multiple)
19    #[command(visible_alias = "issuer", subcommand)]
20    Issuers(IssuerCmd),
21
22    /// Manage clients (the companies you invoice TO)
23    #[command(subcommand)]
24    Clients(ClientCmd),
25
26    /// Manage reusable products/line-items
27    #[command(subcommand)]
28    Products(ProductCmd),
29
30    /// Create, list, show, render, or mark invoices
31    #[command(subcommand)]
32    Invoices(InvoiceCmd),
33
34    /// Template operations (list, preview, set default)
35    #[command(subcommand)]
36    Template(TemplateCmd),
37
38    /// Show / edit config
39    #[command(subcommand)]
40    Config(ConfigCmd),
41
42    /// Self-describing JSON manifest for agents
43    #[command(alias = "info")]
44    AgentInfo,
45
46    /// Install the embedded skill file to ~/.claude/skills/
47    #[command(subcommand)]
48    Skill(SkillCmd),
49
50    /// Run dependency & config diagnostics
51    Doctor,
52
53    /// Self-update from GitHub Releases
54    Update {
55        /// Don't install, just report latest version
56        #[arg(long)]
57        check: bool,
58    },
59}
60
61#[derive(Subcommand, Debug)]
62pub enum IssuerCmd {
63    /// Add a new issuer
64    #[command(alias = "new")]
65    Add {
66        slug: String,
67        #[arg(long)]
68        name: String,
69        #[arg(long)]
70        legal_name: Option<String>,
71        #[arg(long, default_value = "sg")]
72        jurisdiction: String,
73        #[arg(long)]
74        tax_registered: bool,
75        #[arg(long)]
76        tax_id: Option<String>,
77        #[arg(long)]
78        company_no: Option<String>,
79        #[arg(long)]
80        address: String,
81        #[arg(long)]
82        email: Option<String>,
83        #[arg(long)]
84        phone: Option<String>,
85        #[arg(long)]
86        bank_name: Option<String>,
87        #[arg(long)]
88        bank_iban: Option<String>,
89        #[arg(long)]
90        bank_bic: Option<String>,
91        #[arg(long, default_value = "vienna")]
92        template: String,
93        /// Path to a logo image (PNG/SVG/JPG). Rendered in template header.
94        #[arg(long)]
95        logo: Option<String>,
96    },
97    /// Edit an existing issuer — pass only the fields you want to change
98    Edit {
99        slug: String,
100        #[arg(long)]
101        name: Option<String>,
102        #[arg(long)]
103        legal_name: Option<String>,
104        #[arg(long)]
105        jurisdiction: Option<String>,
106        #[arg(long)]
107        tax_registered: Option<bool>,
108        #[arg(long)]
109        tax_id: Option<String>,
110        #[arg(long)]
111        company_no: Option<String>,
112        #[arg(long)]
113        tagline: Option<String>,
114        #[arg(long)]
115        address: Option<String>,
116        #[arg(long)]
117        email: Option<String>,
118        #[arg(long)]
119        phone: Option<String>,
120        #[arg(long)]
121        bank_name: Option<String>,
122        #[arg(long)]
123        bank_iban: Option<String>,
124        #[arg(long)]
125        bank_bic: Option<String>,
126        #[arg(long)]
127        template: Option<String>,
128        #[arg(long)]
129        currency: Option<String>,
130        #[arg(long)]
131        symbol: Option<String>,
132        #[arg(long)]
133        number_format: Option<String>,
134        #[arg(long)]
135        logo: Option<String>,
136    },
137    /// Shorthand: change the issuer's default template
138    SetTemplate {
139        slug: String,
140        template: String,
141    },
142    #[command(alias = "ls")]
143    List,
144    #[command(alias = "get")]
145    Show { slug: String },
146    #[command(alias = "rm")]
147    Delete { slug: String },
148}
149
150#[derive(Subcommand, Debug)]
151pub enum ClientCmd {
152    #[command(alias = "new")]
153    Add {
154        slug: String,
155        #[arg(long)]
156        name: String,
157        #[arg(long)]
158        attn: Option<String>,
159        #[arg(long)]
160        country: Option<String>,
161        #[arg(long)]
162        tax_id: Option<String>,
163        #[arg(long)]
164        address: String,
165        #[arg(long)]
166        email: Option<String>,
167        #[arg(long)]
168        notes: Option<String>,
169        /// Default issuer slug — `invoices new` uses this when `--as` omitted
170        #[arg(long)]
171        default_issuer: Option<String>,
172        /// Preferred template for this client's invoices
173        #[arg(long)]
174        default_template: Option<String>,
175    },
176    /// Edit an existing client — pass only the fields you want to change
177    Edit {
178        slug: String,
179        #[arg(long)]
180        name: Option<String>,
181        #[arg(long)]
182        attn: Option<String>,
183        #[arg(long)]
184        country: Option<String>,
185        #[arg(long)]
186        tax_id: Option<String>,
187        #[arg(long)]
188        address: Option<String>,
189        #[arg(long)]
190        email: Option<String>,
191        #[arg(long)]
192        notes: Option<String>,
193        #[arg(long)]
194        default_issuer: Option<String>,
195        #[arg(long)]
196        default_template: Option<String>,
197    },
198    /// Shorthand: pin a default issuer for this client
199    SetIssuer {
200        slug: String,
201        issuer_slug: String,
202    },
203    /// Shorthand: pin a preferred template for this client
204    SetTemplate {
205        slug: String,
206        template: String,
207    },
208    #[command(alias = "ls")]
209    List,
210    #[command(alias = "get")]
211    Show { slug: String },
212    #[command(alias = "rm")]
213    Delete { slug: String },
214}
215
216#[derive(Subcommand, Debug)]
217pub enum ProductCmd {
218    #[command(alias = "new")]
219    Add {
220        slug: String,
221        #[arg(long)]
222        description: String,
223        #[arg(long)]
224        subtitle: Option<String>,
225        #[arg(long, default_value = "unit")]
226        unit: String,
227        /// Unit price as a decimal (e.g. 220.00)
228        #[arg(long)]
229        price: String,
230        #[arg(long)]
231        currency: String,
232        #[arg(long, default_value = "0")]
233        tax_rate: String,
234    },
235    /// Edit an existing product — pass only the fields you want to change
236    Edit {
237        slug: String,
238        #[arg(long)]
239        description: Option<String>,
240        #[arg(long)]
241        subtitle: Option<String>,
242        #[arg(long)]
243        unit: Option<String>,
244        #[arg(long)]
245        price: Option<String>,
246        #[arg(long)]
247        currency: Option<String>,
248        #[arg(long)]
249        tax_rate: Option<String>,
250    },
251    #[command(alias = "ls")]
252    List,
253    #[command(alias = "get")]
254    Show { slug: String },
255    #[command(alias = "rm")]
256    Delete { slug: String },
257}
258
259#[derive(Subcommand, Debug)]
260pub enum InvoiceCmd {
261    /// Create a new invoice
262    #[command(alias = "new")]
263    New {
264        /// Issuer slug (the "as" — whose invoice is this?). Optional if the
265        /// client has a `default_issuer` pinned.
266        #[arg(long)]
267        r#as: Option<String>,
268        /// Client slug
269        #[arg(long)]
270        client: String,
271        /// Item in the form: "product-slug" OR "Description:qty:price:rate"
272        #[arg(long = "item")]
273        items: Vec<String>,
274        /// Due date (e.g. "2026-05-17" or "30d")
275        #[arg(long, default_value = "30d")]
276        due: String,
277        /// Terms label (default: "Net 30")
278        #[arg(long, default_value = "Net 30")]
279        terms: String,
280        #[arg(long)]
281        notes: Option<String>,
282        /// Currency override (otherwise uses issuer's default)
283        #[arg(long)]
284        currency: Option<String>,
285        /// Reverse-charge flag (EU B2B cross-border)
286        #[arg(long)]
287        reverse_charge: bool,
288        /// Payment URL (Stripe Payment Link, EPC-QR, any URL) encoded as QR
289        #[arg(long)]
290        pay_link: Option<String>,
291        /// Invoice-level discount rate (percent, e.g. "10" for 10% off subtotal)
292        #[arg(long)]
293        discount_rate: Option<String>,
294        /// Invoice-level fixed discount in major units (e.g. "50.00")
295        #[arg(long)]
296        discount_fixed: Option<String>,
297    },
298    /// Edit an existing DRAFT invoice's metadata (issued/paid/void invoices
299    /// are immutable — use a credit note instead).
300    Edit {
301        number: String,
302        #[arg(long)]
303        client: Option<String>,
304        #[arg(long)]
305        due: Option<String>,
306        #[arg(long)]
307        terms: Option<String>,
308        #[arg(long)]
309        notes: Option<String>,
310        #[arg(long)]
311        currency: Option<String>,
312        #[arg(long)]
313        pay_link: Option<String>,
314        #[arg(long)]
315        reverse_charge: Option<bool>,
316        #[arg(long)]
317        discount_rate: Option<String>,
318        #[arg(long)]
319        discount_fixed: Option<String>,
320    },
321    /// Manage line items on a DRAFT invoice
322    #[command(subcommand)]
323    Items(InvoiceItemCmd),
324    /// Issue a credit note against an existing invoice
325    CreditNote {
326        /// Source invoice number
327        number: String,
328        /// Copy ALL line items from source with positive qty — represents a
329        /// full reversal. Mutually exclusive with --item.
330        #[arg(long, conflicts_with = "items")]
331        full: bool,
332        /// Explicit items to include on the credit note (same format as
333        /// `invoices new --item`). Amounts should reflect the refund value.
334        #[arg(long = "item")]
335        items: Vec<String>,
336        #[arg(long)]
337        notes: Option<String>,
338        #[arg(long)]
339        pay_link: Option<String>,
340    },
341    /// Ageing report for unpaid invoices, bucketed 0-30 / 31-60 / 61-90 / 90+
342    Aging {
343        #[arg(long = "as")]
344        issuer: Option<String>,
345    },
346    /// Export invoices as CSV / JSON — month-end accountant handoff
347    Export {
348        /// YYYY-MM-DD inclusive lower bound on issue_date
349        #[arg(long)]
350        from: Option<String>,
351        /// YYYY-MM-DD inclusive upper bound on issue_date
352        #[arg(long)]
353        to: Option<String>,
354        /// csv | json (default csv)
355        #[arg(long, default_value = "csv")]
356        format: String,
357        /// Output path. Defaults to stdout.
358        #[arg(long, short)]
359        out: Option<String>,
360        #[arg(long = "as")]
361        issuer: Option<String>,
362    },
363    /// Clone an existing invoice's line items into a new draft — same client,
364    /// new number + dates. Handy for recurring billing.
365    Duplicate {
366        number: String,
367        /// Override the client (defaults to the source invoice's client)
368        #[arg(long)]
369        client: Option<String>,
370        /// Override the issuer (defaults to the source invoice's issuer)
371        #[arg(long = "as")]
372        r#as: Option<String>,
373        /// New due date (e.g. "2026-05-17" or "30d"). Defaults to "30d".
374        #[arg(long, default_value = "30d")]
375        due: String,
376    },
377    #[command(alias = "ls")]
378    List {
379        #[arg(long)]
380        status: Option<String>,
381        #[arg(long = "as")]
382        issuer: Option<String>,
383        /// Only show invoices past due date and not paid/void
384        #[arg(long)]
385        overdue: bool,
386    },
387    #[command(alias = "get")]
388    Show { number: String },
389    /// Render invoice to PDF
390    Render {
391        number: String,
392        /// Template to use (overrides issuer default)
393        #[arg(long)]
394        template: Option<String>,
395        /// Output path (defaults to ./invoice-<number>.pdf)
396        #[arg(long, short)]
397        out: Option<String>,
398        /// Open the PDF after rendering (macOS open / linux xdg-open)
399        #[arg(long)]
400        open: bool,
401    },
402    /// Mark status (draft/issued/paid/void)
403    Mark {
404        number: String,
405        status: String,
406    },
407    #[command(alias = "rm")]
408    Delete {
409        number: String,
410        /// Allow deleting a non-draft invoice. Breaks number-sequence
411        /// integrity — prefer `mark void` or credit note in most cases.
412        #[arg(long)]
413        force: bool,
414    },
415}
416
417#[derive(Subcommand, Debug)]
418pub enum InvoiceItemCmd {
419    /// Add a line item to a draft invoice
420    Add {
421        number: String,
422        /// Item spec: "product-slug[:qty]" OR "Description:qty:price[:rate]"
423        spec: String,
424        #[arg(long)]
425        subtitle: Option<String>,
426        #[arg(long)]
427        discount_rate: Option<String>,
428        #[arg(long)]
429        discount_fixed: Option<String>,
430    },
431    /// Remove the item at `position` (zero-indexed) from a draft invoice
432    #[command(alias = "rm")]
433    Remove { number: String, position: i64 },
434    /// Edit the item at `position` — any subset of fields
435    Edit {
436        number: String,
437        position: i64,
438        #[arg(long)]
439        description: Option<String>,
440        #[arg(long)]
441        subtitle: Option<String>,
442        #[arg(long)]
443        qty: Option<String>,
444        #[arg(long)]
445        unit: Option<String>,
446        #[arg(long)]
447        price: Option<String>,
448        #[arg(long)]
449        tax_rate: Option<String>,
450        #[arg(long)]
451        discount_rate: Option<String>,
452        #[arg(long)]
453        discount_fixed: Option<String>,
454    },
455}
456
457#[derive(Subcommand, Debug)]
458pub enum TemplateCmd {
459    #[command(alias = "ls")]
460    List,
461    /// Render a preview with synthetic data
462    Preview {
463        name: String,
464        #[arg(long, short)]
465        out: Option<String>,
466    },
467}
468
469#[derive(Subcommand, Debug)]
470pub enum ConfigCmd {
471    Show,
472    Path,
473    Set { key: String, value: String },
474}
475
476#[derive(Subcommand, Debug)]
477pub enum SkillCmd {
478    Install,
479}