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
30fn 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 let client = db::client_by_slug(&conn, &args.client)?;
45 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 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 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 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 let pack_slug = args.pack.clone().unwrap_or_else(|| "standard".to_string());
176 let pack = clauses::load_pack(&args.kind, &pack_slug)?;
177
178 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 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 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
310fn 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
343fn 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
359fn 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
372fn 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#[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#[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
507fn 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
575const STATUSES: &[&str] = &["draft", "sent", "signed", "active", "expired", "terminated"];
578
579fn 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(¤t.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
647fn 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 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}