Skip to main content

contract_cli/commands/
contracts.rs

1use std::collections::BTreeSet;
2use std::path::PathBuf;
3use std::process::Command;
4
5use chrono::{Datelike, NaiveDate};
6use serde_json::{json, Value};
7
8use crate::cli::{ContractCmd, ContractListArgs, ContractNewArgs, ContractRenderArgs, SignArgs};
9use crate::clauses;
10use crate::db::{self, Contract, ContractClauseRow};
11use crate::error::{AppError, Result};
12use crate::output::{print_success, Ctx};
13use crate::render;
14
15pub fn run(cmd: ContractCmd, ctx: Ctx) -> Result<()> {
16    match cmd {
17        ContractCmd::New(args) => cmd_new(args, ctx),
18        ContractCmd::List(args) => cmd_list(args, ctx),
19        ContractCmd::Show { number } => cmd_show(&number, ctx),
20        ContractCmd::Edit(args) => cmd_edit(args, ctx),
21        ContractCmd::Render(args) => cmd_render(args, ctx),
22        ContractCmd::Mark { number, status } => cmd_mark(&number, &status, ctx),
23        ContractCmd::Sign(args) => cmd_sign(args, ctx),
24        ContractCmd::Clauses(cmd) => super::clauses::run(cmd, ctx),
25        ContractCmd::Duplicate(args) => cmd_duplicate(&args.number, args.client, args.r#as, ctx),
26        ContractCmd::Delete(args) => cmd_delete(&args.number, args.force, ctx),
27    }
28}
29
30// ─── new ─────────────────────────────────────────────────────────────────
31
32fn cmd_new(args: ContractNewArgs, ctx: Ctx) -> Result<()> {
33    let kind_names = crate::kinds::names();
34    if !kind_names.contains(&args.kind.as_str()) {
35        return Err(AppError::InvalidInput(format!(
36            "unknown kind '{}'. Expected one of: {}",
37            args.kind,
38            kind_names.join(", ")
39        )));
40    }
41    let effective_iso = match args.effective {
42        Some(s) => parse_date(&s)?,
43        None => chrono::Local::now().date_naive().format("%Y-%m-%d").to_string(),
44    };
45    let end_iso = match args.end {
46        Some(s) => Some(parse_date(&s)?),
47        None => None,
48    };
49    let term_months = match (args.term_months, args.term_years) {
50        (Some(_), Some(_)) => {
51            return Err(AppError::InvalidInput(
52                "pass at most one of --term-months / --term-years".into(),
53            ))
54        }
55        (Some(m), None) => Some(m),
56        (None, Some(y)) => Some(y * 12),
57        (None, None) => None,
58    };
59    if let Some(m) = term_months {
60        if m <= 0 {
61            return Err(AppError::InvalidInput(format!(
62                "invalid term length {m} — must be a positive number of months"
63            )));
64        }
65    }
66    if end_iso.is_some() && term_months.is_some() {
67        return Err(AppError::InvalidInput(
68            "--end and --term-months/--term-years are mutually exclusive".into(),
69        ));
70    }
71    if let Some(t) = &args.template {
72        if !crate::typst_assets::has_template(t)? {
73            return Err(AppError::InvalidInput(format!(
74                "template '{t}' not found. Run: contract template list"
75            )));
76        }
77    }
78
79    // Parse fee
80    let (fee_type, fee_amount_minor, fee_currency) = match args.fee.as_deref() {
81        None => (None, None, None),
82        Some(spec) => {
83            let (t, a, c) = parse_fee(spec)?;
84            (Some(t), Some(a), Some(c))
85        }
86    };
87    let fee_schedule = match args.fee_schedule.as_deref() {
88        Some(v) => Some(validate_choice(
89            "--fee-schedule",
90            v,
91            &["on-completion", "monthly", "on-milestone", "upon-invoice"],
92        )?),
93        None => None,
94    };
95
96    // Build terms_json
97    let mut terms_obj = serde_json::Map::new();
98    if let Some(p) = &args.purpose {
99        terms_obj.insert("purpose".into(), Value::String(p.clone()));
100    }
101    if let Some(n) = args.termination_notice_days {
102        terms_obj.insert("termination_notice_days".into(), json!(n));
103    }
104    if matches!(args.kind.as_str(), "nda" | "ncnda") {
105        let mutuality = match args.mutuality.as_deref() {
106            Some(v) => validate_choice("--mutuality", v, &["mutual", "unilateral"])?,
107            None => "mutual".into(),
108        };
109        let disclosing = match args.disclosing_side.as_deref() {
110            Some(v) => validate_choice("--disclosing-side", v, &["us", "them", "both"])?,
111            None => {
112                if mutuality == "unilateral" {
113                    "us".into()
114                } else {
115                    "both".into()
116                }
117            }
118        };
119        terms_obj.insert("mutuality".into(), Value::String(mutuality));
120        terms_obj.insert("disclosing_side".into(), Value::String(disclosing));
121        terms_obj.insert("confidentiality_years".into(), json!(3));
122    } else if matches!(args.kind.as_str(), "consulting" | "msa" | "sow" | "service") {
123        if !args.deliverables.is_empty() {
124            terms_obj.insert(
125                "deliverables".into(),
126                Value::Array(args.deliverables.iter().cloned().map(Value::String).collect()),
127            );
128        }
129        if let Some(ip) = args.ip_assignment.as_deref() {
130            let ip = validate_choice(
131                "--ip-assignment",
132                ip,
133                &["client", "consultant", "provider", "shared"],
134            )?;
135            terms_obj.insert("ip_assignment".into(), Value::String(ip));
136        }
137        terms_obj
138            .entry("confidentiality_years".to_string())
139            .or_insert(json!(3));
140    }
141    // Free-form --term key=value pairs (the extension point for loan / ncnda
142    // pack {{vars}} like principal_text, repayment_date, interest_text).
143    for spec in &args.terms {
144        let (k, v) = parse_term_kv(spec)?;
145        terms_obj.insert(k, Value::String(v));
146    }
147    let terms_json = Value::Object(terms_obj).to_string();
148
149    // All pure input validation is done — only now touch the database.
150    let mut conn = db::open()?;
151
152    // Resolve client first — needed for default issuer
153    let client = db::client_by_slug(&conn, &args.client)?;
154    // Resolve issuer: --as > client.default_issuer > config.default_issuer
155    let issuer_slug = args
156        .r#as
157        .or(client.default_issuer_slug.clone())
158        .or_else(|| crate::config::load().ok().and_then(|c| c.default_issuer))
159        .ok_or_else(|| {
160            AppError::InvalidInput(
161                "no issuer — pass --as <slug>, pin a default on the client, or set config.default_issuer".into(),
162            )
163        })?;
164    let issuer = db::issuer_by_slug(&conn, &issuer_slug)?;
165
166    let governing_law = args
167        .governing_law
168        .unwrap_or_else(|| issuer.jurisdiction.profile().country.to_string());
169    let venue = args.venue;
170
171    let title = args
172        .title
173        .unwrap_or_else(|| default_title(&args.kind, &issuer.name, &client.name));
174
175
176    // Pick clause pack (default: "standard")
177    let pack_slug = args.pack.clone().unwrap_or_else(|| "standard".to_string());
178    let pack = clauses::load_pack(&args.kind, &pack_slug)?;
179
180    // Build the included clause list with --include / --exclude
181    let mut included: Vec<String> = pack.pack.default_clauses.clone();
182    let mut seen: BTreeSet<String> = included.iter().cloned().collect();
183    for slug in &args.include {
184        if !pack.clauses.contains_key(slug) {
185            return Err(AppError::NotFound(format!(
186                "clause '{slug}' is not defined in pack '{}/{pack_slug}'",
187                args.kind
188            )));
189        }
190        if seen.insert(slug.clone()) {
191            included.push(slug.clone());
192        }
193    }
194    for slug in &args.exclude {
195        included.retain(|s| s != slug);
196    }
197    let clause_rows: Vec<ContractClauseRow> = included
198        .iter()
199        .enumerate()
200        .map(|(i, slug)| ContractClauseRow {
201            id: 0,
202            contract_id: 0,
203            position: i as i64,
204            slug: slug.clone(),
205            heading: None,
206            body: None,
207        })
208        .collect();
209
210    // Generate number
211    let year = NaiveDate::parse_from_str(&effective_iso, "%Y-%m-%d")
212        .map(|d| d.year())
213        .unwrap_or_else(|_| chrono::Local::now().year());
214    let number = db::next_contract_number(&conn, &issuer, year, &args.kind)?;
215
216    let contract = Contract {
217        id: 0,
218        number,
219        kind: args.kind.clone(),
220        issuer_id: issuer.id,
221        client_id: client.id,
222        title,
223        effective_date: effective_iso,
224        end_date: end_iso,
225        term_months,
226        governing_law,
227        venue,
228        status: "draft".into(),
229        sent_at: None,
230        signed_at: None,
231        terminated_at: None,
232        notes: args.notes,
233        fee_type,
234        fee_amount_minor,
235        fee_currency,
236        fee_schedule,
237        terms_json,
238        clause_pack: pack_slug,
239        clause_pack_version: pack.pack.version.clone(),
240        default_template: args.template,
241        signed_by_us_name: None,
242        signed_by_us_title: None,
243        signed_by_us_at: None,
244        signed_by_them_name: None,
245        signed_by_them_title: None,
246        signed_by_them_at: None,
247        created_at: String::new(),
248        updated_at: String::new(),
249    };
250
251    let _id = db::contract_create(&mut conn, &contract, &clause_rows)?;
252    let saved = db::contract_get(&conn, &contract.number)?;
253    print_success(ctx, &saved, |c| {
254        println!(
255            "created {} contract '{}' for {} ({} clauses)",
256            c.kind, c.number, c.title, clause_rows.len()
257        );
258    });
259    Ok(())
260}
261
262fn default_title(kind: &str, issuer_name: &str, client_name: &str) -> String {
263    match kind {
264        "nda" => format!("NDA — {issuer_name} & {client_name}"),
265        "ncnda" => format!("NCNDA — {issuer_name} & {client_name}"),
266        "consulting" => format!("Consulting Agreement — {issuer_name} × {client_name}"),
267        "msa" => format!("Master Services Agreement — {issuer_name} & {client_name}"),
268        "sow" => format!("Statement of Work — {issuer_name} × {client_name}"),
269        "service" => format!("Service Agreement — {issuer_name} for {client_name}"),
270        "loan" => format!("Loan Agreement — {issuer_name} & {client_name}"),
271        _ => format!("Agreement — {issuer_name} & {client_name}"),
272    }
273}
274
275fn parse_date(s: &str) -> Result<String> {
276    NaiveDate::parse_from_str(s, "%Y-%m-%d")
277        .map(|d| d.format("%Y-%m-%d").to_string())
278        .map_err(|_| AppError::InvalidInput(format!("invalid date '{s}' — expected YYYY-MM-DD")))
279}
280
281fn parse_fee(spec: &str) -> Result<(String, i64, String)> {
282    let parts: Vec<&str> = spec.split(':').collect();
283    if parts.len() != 3 {
284        return Err(AppError::InvalidInput(format!(
285            "fee spec '{spec}' — expected type:amount:currency (e.g. fixed:8400:SGD)"
286        )));
287    }
288    let kind = parts[0].to_lowercase();
289    if !matches!(kind.as_str(), "fixed" | "hourly" | "daily" | "retainer") {
290        return Err(AppError::InvalidInput(format!(
291            "unknown fee type '{kind}' (expected fixed | hourly | daily | retainer)"
292        )));
293    }
294    // Strip a "/month"-style cadence suffix from the currency segment; the
295    // cadence belongs in --fee-schedule, not the currency code.
296    let currency = parts[2]
297        .split('/')
298        .next()
299        .unwrap_or("")
300        .trim()
301        .to_uppercase();
302    if currency.len() != 3 || !currency.chars().all(|c| c.is_ascii_alphabetic()) {
303        return Err(AppError::InvalidInput(format!(
304            "invalid currency '{}' — expected a 3-letter ISO code (e.g. SGD, GBP, USD)",
305            parts[2]
306        )));
307    }
308    let amount_minor = parse_amount_minor(parts[1])?;
309    Ok((kind, amount_minor, currency))
310}
311
312/// Parse a decimal money amount into integer minor units with checked
313/// arithmetic — no f64, so no silent overflow saturation or cent drift.
314/// Accepts "8400", "8400.5", "8400.50"; rejects sub-cent precision,
315/// exponents, separators, zero, and negatives.
316fn parse_amount_minor(text: &str) -> Result<i64> {
317    let bad = |why: &str| AppError::InvalidInput(format!("invalid fee amount '{text}': {why}"));
318    let t = text.trim();
319    let (int_part, frac_part) = match t.split_once('.') {
320        Some((i, f)) => (i, f),
321        None => (t, ""),
322    };
323    if int_part.is_empty() || !int_part.chars().all(|c| c.is_ascii_digit()) {
324        return Err(bad("must be a plain positive number like 8400 or 8400.50"));
325    }
326    if frac_part.len() > 2 || !frac_part.chars().all(|c| c.is_ascii_digit()) {
327        return Err(bad("at most two decimal places (whole cents)"));
328    }
329    let whole: i64 = int_part.parse().map_err(|_| bad("too large"))?;
330    let cents: i64 = match frac_part.len() {
331        0 => 0,
332        1 => frac_part.parse::<i64>().unwrap_or(0) * 10,
333        _ => frac_part.parse::<i64>().unwrap_or(0),
334    };
335    let minor = whole
336        .checked_mul(100)
337        .and_then(|w| w.checked_add(cents))
338        .ok_or_else(|| bad("too large"))?;
339    if minor <= 0 {
340        return Err(bad("must be positive"));
341    }
342    Ok(minor)
343}
344
345/// Validate a repeatable --term key=value flag into (key, value).
346fn parse_term_kv(spec: &str) -> Result<(String, String)> {
347    let (k, v) = spec.split_once('=').ok_or_else(|| {
348        AppError::InvalidInput(format!(
349            "invalid --term '{spec}' — expected key=value (e.g. repayment_date=2026-12-01)"
350        ))
351    })?;
352    let k = k.trim();
353    if k.is_empty() || !k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
354        return Err(AppError::InvalidInput(format!(
355            "invalid --term key '{k}' — use snake_case letters/digits"
356        )));
357    }
358    Ok((k.to_string(), v.to_string()))
359}
360
361/// Enum-flag validation: lowercase + membership check with a helpful error.
362fn validate_choice(flag: &str, value: &str, allowed: &[&str]) -> Result<String> {
363    let v = value.trim().to_lowercase();
364    if allowed.contains(&v.as_str()) {
365        Ok(v)
366    } else {
367        Err(AppError::InvalidInput(format!(
368            "invalid {flag} '{value}' (expected one of: {})",
369            allowed.join(" | ")
370        )))
371    }
372}
373
374// ─── list ─────────────────────────────────────────────────────────────────
375
376fn cmd_list(args: ContractListArgs, ctx: Ctx) -> Result<()> {
377    let conn = db::open()?;
378    let rows = db::contract_list(
379        &conn,
380        args.kind.as_deref(),
381        args.status.as_deref(),
382        args.issuer.as_deref(),
383    )?;
384    print_success(ctx, &rows, |rs| {
385        if rs.is_empty() {
386            println!("(no contracts — create one with: contract new --kind nda --client X)");
387        } else {
388            for c in rs {
389                println!(
390                    "  {:<28} {:<10} {:<8} {}",
391                    c.number, c.kind, c.status, c.title
392                );
393            }
394        }
395    });
396    Ok(())
397}
398
399// ─── show ─────────────────────────────────────────────────────────────────
400
401#[derive(serde::Serialize)]
402struct ContractView<'a> {
403    #[serde(flatten)]
404    contract: &'a Contract,
405    clauses: Vec<ContractClauseRow>,
406}
407
408fn cmd_show(number: &str, ctx: Ctx) -> Result<()> {
409    let conn = db::open()?;
410    let c = db::contract_get_or_404(&conn, number)?;
411    let clauses = db::clauses_for(&conn, c.id)?;
412    let view = ContractView { contract: &c, clauses: clauses.clone() };
413    print_success(ctx, &view, |v| {
414        println!("Contract: {}", v.contract.number);
415        println!("  Kind:           {}", v.contract.kind);
416        println!("  Title:          {}", v.contract.title);
417        println!("  Status:         {}", v.contract.status);
418        println!("  Effective:      {}", v.contract.effective_date);
419        if let Some(e) = &v.contract.end_date {
420            println!("  End:            {}", e);
421        }
422        if let Some(t) = v.contract.term_months {
423            println!("  Term:           {} months", t);
424        }
425        println!("  Governing law:  {}", v.contract.governing_law);
426        if let Some(fee) = &v.contract.fee_type {
427            println!(
428                "  Fee:            {} {} {}",
429                fee,
430                v.contract.fee_amount_minor.map(|m| (m as f64) / 100.0).unwrap_or(0.0),
431                v.contract.fee_currency.clone().unwrap_or_default()
432            );
433        }
434        println!("  Pack:           {} v{}", v.contract.clause_pack, v.contract.clause_pack_version);
435        println!("  Clauses ({}):", v.clauses.len());
436        for cl in &v.clauses {
437            println!("    {:>2}. {}", cl.position + 1, cl.slug);
438        }
439    });
440    Ok(())
441}
442
443// ─── edit ─────────────────────────────────────────────────────────────────
444
445#[allow(clippy::too_many_arguments)]
446fn cmd_edit(args: crate::cli::ContractEditArgs, ctx: Ctx) -> Result<()> {
447    let conn = db::open()?;
448    let number = args.number;
449    let mut c = db::contract_get_or_404(&conn, &number)?;
450    if let Some(slug) = args.client {
451        c.client_id = db::client_by_slug(&conn, &slug)?.id;
452    }
453    if let Some(v) = args.title { c.title = v; }
454    if let Some(v) = args.effective { c.effective_date = parse_date(&v)?; }
455    if let Some(v) = args.end {
456        c.end_date = Some(parse_date(&v)?);
457        c.term_months = None;
458    }
459    if let Some(v) = args.term_months {
460        if v <= 0 {
461            return Err(AppError::InvalidInput(format!(
462                "invalid term length {v} — must be a positive number of months"
463            )));
464        }
465        c.term_months = Some(v);
466        c.end_date = None;
467    }
468    if let Some(v) = args.governing_law { c.governing_law = v; }
469    if let Some(v) = args.venue { c.venue = Some(v); }
470    if let Some(v) = args.fee {
471        let (t, a, cur) = parse_fee(&v)?;
472        c.fee_type = Some(t);
473        c.fee_amount_minor = Some(a);
474        c.fee_currency = Some(cur);
475    }
476    if let Some(v) = args.fee_schedule.as_deref() {
477        c.fee_schedule = Some(validate_choice(
478            "--fee-schedule",
479            v,
480            &["on-completion", "monthly", "on-milestone", "upon-invoice"],
481        )?);
482    }
483    if !args.terms.is_empty() {
484        let mut terms: serde_json::Map<String, Value> =
485            c.terms_json.parse::<Value>().ok()
486                .and_then(|v| v.as_object().cloned())
487                .unwrap_or_default();
488        for spec in &args.terms {
489            let (k, v) = parse_term_kv(spec)?;
490            terms.insert(k, Value::String(v));
491        }
492        c.terms_json = Value::Object(terms).to_string();
493    }
494    if let Some(v) = args.notes { c.notes = Some(v); }
495    if let Some(v) = args.template {
496        if !crate::typst_assets::has_template(&v)? {
497            return Err(AppError::InvalidInput(format!(
498                "template '{v}' not found. Run: contract template list"
499            )));
500        }
501        c.default_template = Some(v);
502    }
503    db::contract_update_draft(&conn, &c)?;
504    let saved = db::contract_get(&conn, &number)?;
505    print_success(ctx, &saved, |s| println!("updated draft '{}'", s.number));
506    Ok(())
507}
508
509// ─── render ───────────────────────────────────────────────────────────────
510
511fn cmd_render(args: ContractRenderArgs, ctx: Ctx) -> Result<()> {
512    let conn = db::open()?;
513    let c = db::contract_get_or_404(&conn, &args.number)?;
514    let issuer = db::issuer_list(&conn)?
515        .into_iter()
516        .find(|i| i.id == c.issuer_id)
517        .ok_or_else(|| AppError::NotFound(format!("issuer #{}", c.issuer_id)))?;
518    let client = db::client_list(&conn)?
519        .into_iter()
520        .find(|x| x.id == c.client_id)
521        .ok_or_else(|| AppError::NotFound(format!("client #{}", c.client_id)))?;
522    let clause_rows = db::clauses_for(&conn, c.id)?;
523    let pack = clauses::load_pack(&c.kind, &c.clause_pack)?;
524
525    let template = args
526        .template
527        .or_else(|| c.default_template.clone())
528        .unwrap_or_else(|| "helvetica-nera".to_string());
529
530    let out_path: PathBuf = match args.out {
531        Some(p) => PathBuf::from(p),
532        None => {
533            let dir = issuer
534                .default_output_dir
535                .clone()
536                .map(|s| PathBuf::from(render::expand_tilde(&s)))
537                .unwrap_or_else(render::default_output_dir);
538            std::fs::create_dir_all(&dir)?;
539            dir.join(format!("{}.pdf", c.number))
540        }
541    };
542
543    let mut data = render::build_render_data(
544        &c, &issuer, &client, &clause_rows, &pack, args.draft, args.final_render,
545    )?;
546    render::render_to_pdf(&template, &mut data, &issuer, &out_path)?;
547
548    if args.open {
549        let opener = if cfg!(target_os = "macos") {
550            "open"
551        } else {
552            "xdg-open"
553        };
554        let _ = Command::new(opener).arg(&out_path).status();
555    }
556
557    #[derive(serde::Serialize)]
558    struct Out {
559        number: String,
560        template: String,
561        out: String,
562        draft_watermark: bool,
563    }
564    let report = Out {
565        number: c.number.clone(),
566        template,
567        out: out_path.display().to_string(),
568        draft_watermark: data.draft_watermark,
569    };
570    print_success(ctx, &report, |r| {
571        let mark = if r.draft_watermark { " (DRAFT)" } else { "" };
572        println!("rendered → {}{}", r.out, mark);
573    });
574    Ok(())
575}
576
577// ─── mark / sign ──────────────────────────────────────────────────────────
578
579const STATUSES: &[&str] = &["draft", "sent", "signed", "active", "expired", "terminated"];
580
581/// Legal lifecycle moves, as explicit edges. sent→draft recalls an unsigned
582/// draft; everything after signing only moves forward — reopening an executed
583/// contract would unlock clause edits on a document the counterparty already
584/// signed. No stage-skipping (a draft cannot jump straight to active).
585fn transition_allowed(from: &str, to: &str) -> bool {
586    if from == to {
587        return true;
588    }
589    matches!(
590        (from, to),
591        ("draft", "sent" | "signed")
592            | ("sent", "draft" | "signed")
593            | ("signed", "active" | "expired" | "terminated")
594            | ("active", "expired" | "terminated")
595    )
596}
597
598fn cmd_mark(number: &str, status: &str, ctx: Ctx) -> Result<()> {
599    let status = validate_choice("status", status, STATUSES)?;
600    let conn = db::open()?;
601    let current = db::contract_get_or_404(&conn, number)?;
602    if !transition_allowed(&current.status, &status) {
603        return Err(AppError::InvalidInput(format!(
604            "cannot mark '{}' {} → {} (executed contracts only move forward: signed → active → expired/terminated)",
605            number, current.status, status
606        )));
607    }
608    db::contract_set_status(&conn, number, &status)?;
609    let c = db::contract_get(&conn, number)?;
610    print_success(ctx, &c, |c| println!("'{}' → {}", c.number, c.status));
611    Ok(())
612}
613
614fn cmd_sign(args: SignArgs, ctx: Ctx) -> Result<()> {
615    let side = validate_choice("--side", &args.side, &["us", "them"])?;
616    let conn = db::open()?;
617    let existing = db::contract_get_or_404(&conn, &args.number)?;
618    let already = match side.as_str() {
619        "us" => existing.signed_by_us_name.is_some(),
620        _ => existing.signed_by_them_name.is_some(),
621    };
622    if already && !args.force {
623        return Err(AppError::InvalidInput(format!(
624            "'{}' already has a recorded {} signature. Pass --force to overwrite it.",
625            args.number, side
626        )));
627    }
628    let date_iso = match args.date {
629        Some(s) => parse_date(&s)?,
630        None => chrono::Local::now().date_naive().format("%Y-%m-%d").to_string(),
631    };
632    let c = db::contract_record_signature(
633        &conn,
634        &args.number,
635        &side,
636        &args.name,
637        args.title.as_deref(),
638        &date_iso,
639    )?;
640    print_success(ctx, &c, |c| {
641        println!(
642            "recorded {} signature on '{}'. Status: {}.",
643            side, c.number, c.status
644        );
645    });
646    Ok(())
647}
648
649// ─── duplicate / delete ───────────────────────────────────────────────────
650
651fn cmd_duplicate(
652    number: &str,
653    client: Option<String>,
654    r#as: Option<String>,
655    ctx: Ctx,
656) -> Result<()> {
657    let mut conn = db::open()?;
658    let src = db::contract_get_or_404(&conn, number)?;
659    let src_clauses = db::clauses_for(&conn, src.id)?;
660
661    let issuer = match r#as {
662        Some(slug) => db::issuer_by_slug(&conn, &slug)?,
663        None => db::issuer_list(&conn)?
664            .into_iter()
665            .find(|i| i.id == src.issuer_id)
666            .ok_or_else(|| AppError::NotFound(format!("issuer #{}", src.issuer_id)))?,
667    };
668    let client_id = match client {
669        Some(slug) => db::client_by_slug(&conn, &slug)?.id,
670        None => src.client_id,
671    };
672    let year = chrono::Local::now().year();
673    let new_number = db::next_contract_number(&conn, &issuer, year, &src.kind)?;
674    let today = chrono::Local::now().date_naive().format("%Y-%m-%d").to_string();
675    let new_contract = Contract {
676        id: 0,
677        number: new_number.clone(),
678        kind: src.kind.clone(),
679        issuer_id: issuer.id,
680        client_id,
681        title: src.title.clone(),
682        effective_date: today,
683        // A copied absolute end date would predate the new effective date;
684        // keep relative terms, drop absolute ones for the user to re-set.
685        end_date: None,
686        term_months: src.term_months,
687        governing_law: src.governing_law.clone(),
688        venue: src.venue.clone(),
689        status: "draft".into(),
690        sent_at: None,
691        signed_at: None,
692        terminated_at: None,
693        notes: src.notes.clone(),
694        fee_type: src.fee_type.clone(),
695        fee_amount_minor: src.fee_amount_minor,
696        fee_currency: src.fee_currency.clone(),
697        fee_schedule: src.fee_schedule.clone(),
698        terms_json: src.terms_json.clone(),
699        clause_pack: src.clause_pack.clone(),
700        clause_pack_version: src.clause_pack_version.clone(),
701        default_template: src.default_template.clone(),
702        signed_by_us_name: None,
703        signed_by_us_title: None,
704        signed_by_us_at: None,
705        signed_by_them_name: None,
706        signed_by_them_title: None,
707        signed_by_them_at: None,
708        created_at: String::new(),
709        updated_at: String::new(),
710    };
711    let fresh_clauses: Vec<ContractClauseRow> = src_clauses
712        .into_iter()
713        .map(|c| ContractClauseRow {
714            id: 0,
715            contract_id: 0,
716            position: c.position,
717            slug: c.slug,
718            heading: c.heading,
719            body: c.body,
720        })
721        .collect();
722    db::contract_create(&mut conn, &new_contract, &fresh_clauses)?;
723    let saved = db::contract_get(&conn, &new_number)?;
724    print_success(ctx, &saved, |c| {
725        println!("duplicated '{}' → '{}'", number, c.number);
726    });
727    Ok(())
728}
729
730fn cmd_delete(number: &str, force: bool, ctx: Ctx) -> Result<()> {
731    let conn = db::open()?;
732    db::contract_delete(&conn, number, force)?;
733    print_success(ctx, &number, |n| println!("deleted contract '{n}'"));
734    Ok(())
735}