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 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 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 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 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 let mut conn = db::open()?;
151
152 let client = db::client_by_slug(&conn, &args.client)?;
154 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 let pack_slug = args.pack.clone().unwrap_or_else(|| "standard".to_string());
178 let pack = clauses::load_pack(&args.kind, &pack_slug)?;
179
180 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 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 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
312fn 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
345fn 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
361fn 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
374fn 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#[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#[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
509fn 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
577const STATUSES: &[&str] = &["draft", "sent", "signed", "active", "expired", "terminated"];
580
581fn 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(¤t.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
649fn 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 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}