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