1use std::collections::BTreeMap;
10use std::path::Path;
11use std::process::Command;
12
13use chrono::NaiveDate;
14use serde::Serialize;
15
16use crate::clauses::{self, Pack};
17use crate::db::{Client, Contract, ContractClauseRow, Issuer};
18use crate::error::{AppError, Result};
19use crate::typst_assets;
20
21#[derive(Debug, Serialize)]
24#[serde(rename_all = "kebab-case")]
25pub struct ContractRenderData {
26 pub kind: String,
27 pub kind_label: String,
31 pub number: String,
34 pub subtitle: Option<String>,
39 pub effective_date_display: String,
42 pub end_date_display: Option<String>,
43 pub term_text: String,
44 pub term_short: String,
46 pub governing_law: String,
47 pub jurisdiction_phrase: String,
48 pub venue: Option<String>,
49 pub status: String,
50 pub draft_watermark: bool,
51 pub fee_text: Option<String>,
52 pub fee_short: Option<String>,
54 pub our_party: PartyData,
55 pub their_party: PartyData,
56 pub parties_prose: Vec<String>,
61 pub clauses: Vec<ClauseRenderData>,
62 pub signature: SignatureBlock,
63 pub logo: Option<String>,
64 pub internal_notes: Option<String>,
65}
66
67#[derive(Debug, Serialize)]
68#[serde(rename_all = "kebab-case")]
69pub struct PartyData {
70 pub role_label: String,
71 pub display_name: String,
72 pub legal_name: Option<String>,
73 pub company_no: Option<String>,
74 pub address: Vec<String>,
75 pub jurisdiction: Option<String>,
76 pub email: Option<String>,
77 pub attn: Option<String>,
78}
79
80#[derive(Debug, Serialize)]
81#[serde(rename_all = "kebab-case")]
82pub struct ClauseRenderData {
83 pub number: i64,
84 pub slug: String,
85 pub heading: String,
86 pub body: String,
87}
88
89#[derive(Debug, Serialize)]
90#[serde(rename_all = "kebab-case")]
91pub struct SignatureBlock {
92 pub our_label: String,
93 pub our_name: String,
94 pub our_signer_name: Option<String>,
95 pub our_signer_title: Option<String>,
96 pub our_signer_date: Option<String>,
97 pub their_label: String,
98 pub their_name: String,
99 pub their_signer_name: Option<String>,
100 pub their_signer_title: Option<String>,
101 pub their_signer_date: Option<String>,
102}
103
104fn fmt_date(iso: &str) -> String {
107 NaiveDate::parse_from_str(iso, "%Y-%m-%d")
108 .map(|d| d.format("%-d %B %Y").to_string())
109 .unwrap_or_else(|_| iso.to_string())
110}
111
112fn kind_label(kind: &str, terms: &serde_json::Value) -> String {
113 match kind {
114 "consulting" => "Consulting Services Agreement".into(),
115 "nda" => {
116 if terms.get("mutuality").and_then(|v| v.as_str()) == Some("unilateral") {
117 "Non-Disclosure Agreement".into()
118 } else {
119 "Mutual Non-Disclosure Agreement".into()
120 }
121 }
122 "ncnda" => "Non-Circumvention & Non-Disclosure Agreement".into(),
123 "loan" => "Loan Agreement".into(),
124 "msa" => "Master Services Agreement".into(),
125 "sow" => "Statement of Work".into(),
126 "service" => "Service Agreement".into(),
127 other => format!("{} Agreement", capitalize(other)),
128 }
129}
130
131fn capitalize(s: &str) -> String {
132 let mut chars = s.chars();
133 chars.next().map(|c| c.to_uppercase().collect::<String>() + chars.as_str())
134 .unwrap_or_default()
135}
136
137fn party_role_labels(kind: &str, terms: &serde_json::Value) -> (String, String) {
138 match kind {
139 "nda" => {
140 let mutuality = terms.get("mutuality").and_then(|v| v.as_str()).unwrap_or("mutual");
141 if mutuality == "mutual" {
142 ("Party A".into(), "Party B".into())
143 } else {
144 let disclosing = terms.get("disclosing_side").and_then(|v| v.as_str()).unwrap_or("us");
145 if disclosing == "us" {
146 ("Disclosing Party".into(), "Receiving Party".into())
147 } else {
148 ("Receiving Party".into(), "Disclosing Party".into())
149 }
150 }
151 }
152 "ncnda" => ("Party A".into(), "Party B".into()),
153 _ => crate::kinds::get(kind)
154 .map(|k| (k.roles.0.to_string(), k.roles.1.to_string()))
155 .unwrap_or_else(|| ("Party A".into(), "Party B".into())),
156 }
157}
158
159fn jurisdiction_phrase(law: &str) -> String {
160 let l = law.trim();
162 let lower = l.to_lowercase();
163 if lower.contains("singapore") {
164 "the courts of Singapore".into()
165 } else if lower.contains("england") || lower.contains("wales") || lower.contains("united kingdom") || lower == "uk" {
166 "the courts of England and Wales".into()
167 } else if lower.contains("delaware") {
168 "the state and federal courts located in Delaware".into()
169 } else if lower.contains("new york") {
170 "the state and federal courts of New York".into()
171 } else if lower.contains("hong kong") {
172 "the courts of the Hong Kong Special Administrative Region".into()
173 } else if lower.contains("germany") {
174 "the courts of Frankfurt am Main, Germany".into()
175 } else if lower.contains("france") {
176 "the courts of Paris, France".into()
177 } else {
178 format!("the courts of {}", l)
179 }
180}
181
182fn term_text(c: &Contract) -> String {
183 if let Some(end) = &c.end_date {
184 format!("until {}", fmt_date(end))
185 } else if let Some(m) = c.term_months {
186 if m % 12 == 0 {
187 let years = m / 12;
188 if years == 1 {
189 "for one year from the Effective Date".into()
190 } else {
191 format!("for {} years from the Effective Date", years)
192 }
193 } else if m == 1 {
194 "for one month from the Effective Date".into()
195 } else {
196 format!("for {} months from the Effective Date", m)
197 }
198 } else {
199 "indefinitely until terminated as provided below".into()
200 }
201}
202
203fn term_short(c: &Contract) -> String {
204 if let Some(end) = &c.end_date {
205 NaiveDate::parse_from_str(end, "%Y-%m-%d")
207 .map(|d| format!("until {}", d.format("%-d %b %Y")))
208 .unwrap_or_else(|_| format!("until {end}"))
209 } else if let Some(m) = c.term_months {
210 if m % 12 == 0 {
211 let y = m / 12;
212 if y == 1 { "1 year".into() } else { format!("{y} years") }
213 } else if m == 1 {
214 "1 month".into()
215 } else {
216 format!("{m} months")
217 }
218 } else {
219 "Indefinite".into()
220 }
221}
222
223fn fee_short(c: &Contract) -> Option<String> {
224 let kind = c.fee_type.as_deref()?;
225 let amt = c.fee_amount_minor?;
226 let cur = c.fee_currency.clone().unwrap_or_default();
227 let symbol = finance_core::money::currency_symbol(&cur);
228 let amt_str = finance_core::money::MinorUnits(amt).format_number();
229 let amt_display = if symbol.is_empty() {
230 format!("{} {}", amt_str, cur)
231 } else {
232 format!("{}{}", symbol, amt_str)
233 };
234 Some(match kind {
235 "fixed" => format!("{amt_display} fixed"),
236 "hourly" => format!("{amt_display}/hr"),
237 "daily" => format!("{amt_display}/day"),
238 "retainer" => format!("{amt_display}/month"),
239 _ => amt_display,
240 })
241}
242
243fn ip_assignment_text(terms: &serde_json::Value) -> String {
244 let mode = terms.get("ip_assignment").and_then(|v| v.as_str()).unwrap_or("client");
245 match mode {
246 "client" => "All deliverables produced specifically for the Client under this engagement (the “Deliverables”) belong to the Client. The Consultant assigns to the Client, on payment of the relevant fees, all right, title, and interest in the Deliverables.".into(),
247 "consultant" | "provider" => "The Consultant retains ownership of all deliverables. The Client receives a non-exclusive, perpetual, worldwide, royalty-free licence to use them for its internal business purposes.".into(),
248 "shared" => "The parties jointly own the deliverables. Each party may use them for any purpose without accounting to the other.".into(),
249 _ => "All deliverables produced specifically for the Client under this engagement belong to the Client, on payment of the relevant fees.".into(),
250 }
251}
252
253fn fee_text(c: &Contract) -> Option<String> {
254 let kind = c.fee_type.as_deref()?;
255 let amt = c.fee_amount_minor?;
256 let cur = c.fee_currency.clone().unwrap_or_default();
257 let symbol = finance_core::money::currency_symbol(&cur);
258 let amt_str = finance_core::money::MinorUnits(amt).format_number();
259 let amt_display = if symbol.is_empty() {
260 format!("{} {}", amt_str, cur)
261 } else {
262 format!("{}{}", symbol, amt_str)
263 };
264 let sched_phrase = match c.fee_schedule.as_deref() {
265 Some("on-completion") => ", payable on completion of the Services",
266 Some("on-milestone") => ", payable on completion of each milestone",
267 Some("monthly") => ", invoiced monthly in arrears",
268 Some("upon-invoice") => ", invoiced as work is performed",
269 _ => "",
270 };
271 Some(match kind {
272 "fixed" => format!("a fixed fee of {amt_display}{sched_phrase}"),
273 "hourly" => format!("an hourly rate of {amt_display}/hour{sched_phrase}"),
274 "daily" => format!("a daily rate of {amt_display}/day{sched_phrase}"),
275 "retainer" => format!("a monthly retainer of {amt_display}{sched_phrase}"),
276 _ => format!("{amt_display}{sched_phrase}"),
277 })
278}
279
280fn deliverables_block(terms: &serde_json::Value) -> String {
281 let items: Vec<String> = terms
282 .get("deliverables")
283 .and_then(|v| v.as_array())
284 .map(|arr| {
285 arr.iter()
286 .filter_map(|x| x.as_str().map(str::to_string))
287 .collect()
288 })
289 .unwrap_or_default();
290 if items.is_empty() {
291 "To be agreed in writing by the parties.".into()
292 } else {
293 items
294 .into_iter()
295 .map(|s| format!("- {s}"))
296 .collect::<Vec<_>>()
297 .join("\n")
298 }
299}
300
301fn esc_markup(s: &str) -> String {
305 let mut out = String::with_capacity(s.len());
306 for c in s.chars() {
307 if matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}') {
310 out.push(' ');
311 continue;
312 }
313 if matches!(
314 c,
315 '\\' | '#' | '*' | '_' | '`' | '$' | '<' | '>' | '@' | '[' | ']' | '~' | '/' | '-'
316 | '=' | '+'
317 ) {
318 out.push('\\');
319 }
320 out.push(c);
321 }
322 out
323}
324
325fn party_intro_prose(p: &PartyData) -> String {
331 let legal = p.legal_name.clone().unwrap_or_else(|| p.display_name.clone());
332 let name_upper = esc_markup(&legal.to_uppercase());
333 let addr = esc_markup(&p.address.join(", "));
334 let looks_like_company = looks_like_company_name(&legal);
335 let mut qualifier = String::new();
336 if let Some(j) = &p.jurisdiction {
337 if looks_like_company {
338 qualifier.push_str(&format!(", a company incorporated in {}", esc_markup(j)));
339 if let Some(co) = &p.company_no {
340 qualifier.push_str(&format!(" (Co. No. {})", esc_markup(co)));
341 }
342 }
343 } else if let Some(co) = &p.company_no {
344 qualifier.push_str(&format!(", company no. {}", esc_markup(co)));
346 }
347 format!(
349 "*{name_upper}* of {addr}{qualifier} (the \"{role}\")",
350 role = esc_markup(&p.role_label)
351 )
352}
353
354fn looks_like_company_name(s: &str) -> bool {
355 let lower = s.to_lowercase();
356 [
357 " ltd", " limited", " inc", " incorporated", " corp", " corporation",
358 " pte", " llc", " llp", " plc", " gmbh", " ag", " sa", " s.a.",
359 " sas", " bv", " nv", " pty",
360 ]
361 .iter()
362 .any(|s| lower.contains(s))
363}
364
365fn auto_default_title(kind: &str, issuer_name: &str, client_name: &str) -> String {
369 match kind {
370 "nda" => format!("NDA — {issuer_name} & {client_name}"),
371 "ncnda" => format!("NCNDA — {issuer_name} & {client_name}"),
372 "consulting" => format!("Consulting Agreement — {issuer_name} × {client_name}"),
373 "msa" => format!("Master Services Agreement — {issuer_name} & {client_name}"),
374 "sow" => format!("Statement of Work — {issuer_name} × {client_name}"),
375 "service" => format!("Service Agreement — {issuer_name} for {client_name}"),
376 "loan" => format!("Loan Agreement — {issuer_name} & {client_name}"),
377 _ => format!("Agreement — {issuer_name} & {client_name}"),
378 }
379}
380
381fn party_display(issuer: &Issuer, role_label: &str) -> PartyData {
382 PartyData {
383 role_label: role_label.to_string(),
384 display_name: issuer.name.clone(),
385 legal_name: issuer.legal_name.clone(),
386 company_no: issuer.company_no.clone(),
387 address: issuer.address.clone(),
388 jurisdiction: Some(issuer.jurisdiction.profile().country.to_string()),
389 email: issuer.email.clone(),
390 attn: None,
391 }
392}
393
394fn client_display(client: &Client, role_label: &str) -> PartyData {
395 PartyData {
396 role_label: role_label.to_string(),
397 display_name: client.name.clone(),
398 legal_name: client.legal_name.clone(),
399 company_no: client.company_no.clone(),
400 address: client.address.clone(),
401 jurisdiction: client.legal_jurisdiction.clone(),
402 email: client.email.clone(),
403 attn: client.attn.clone(),
404 }
405}
406
407fn vars_from(
408 contract: &Contract,
409 issuer: &Issuer,
410 client: &Client,
411 terms: &serde_json::Value,
412 our_role: &str,
413 their_role: &str,
414) -> BTreeMap<String, String> {
415 let mut v = BTreeMap::new();
416 let our_legal = issuer.legal_name.clone().unwrap_or_else(|| issuer.name.clone());
417 let their_legal = client.legal_name.clone().unwrap_or_else(|| client.name.clone());
418 v.insert("our_name".into(), issuer.name.clone());
419 v.insert("our_legal_name".into(), our_legal);
420 v.insert("our_role".into(), our_role.to_string());
421 v.insert("their_name".into(), client.name.clone());
422 v.insert("their_legal_name".into(), their_legal);
423 v.insert("their_role".into(), their_role.to_string());
424 v.insert("title".into(), contract.title.clone());
425 v.insert("number".into(), contract.number.clone());
426 v.insert("effective_date".into(), fmt_date(&contract.effective_date));
427 v.insert(
428 "end_date".into(),
429 contract.end_date.as_deref().map(fmt_date).unwrap_or_default(),
430 );
431 v.insert("term_text".into(), term_text(contract));
432 v.insert("governing_law".into(), contract.governing_law.clone());
433 v.insert(
434 "jurisdiction_phrase".into(),
435 jurisdiction_phrase(&contract.governing_law),
436 );
437 v.insert(
438 "venue".into(),
439 contract.venue.clone().unwrap_or_default(),
440 );
441 v.insert(
443 "purpose".into(),
444 terms
445 .get("purpose")
446 .and_then(|x| x.as_str())
447 .unwrap_or("the matters described in the title")
448 .to_string(),
449 );
450 v.insert(
451 "mutuality".into(),
452 terms
453 .get("mutuality")
454 .and_then(|x| x.as_str())
455 .unwrap_or("mutual")
456 .to_string(),
457 );
458 v.insert(
459 "confidentiality_years".into(),
460 terms
461 .get("confidentiality_years")
462 .and_then(|x| x.as_i64())
463 .map(|n| n.to_string())
464 .unwrap_or_else(|| "3".into()),
465 );
466 v.insert(
467 "termination_notice_days".into(),
468 terms
469 .get("termination_notice_days")
470 .and_then(|x| x.as_i64())
471 .or_else(|| {
472 contract
473 .terms_json
474 .parse::<serde_json::Value>()
475 .ok()
476 .and_then(|t| t.get("termination_notice_days").and_then(|x| x.as_i64()))
477 })
478 .map(|n| n.to_string())
479 .unwrap_or_else(|| "30".into()),
480 );
481 v.insert(
483 "deliverables_block".into(),
484 deliverables_block(terms),
485 );
486 v.insert("ip_assignment_text".into(), ip_assignment_text(terms));
487 v.insert(
488 "fee_text".into(),
489 fee_text(contract).unwrap_or_else(|| "as separately agreed in writing".into()),
490 );
491 if let Some(obj) = terms.as_object() {
495 for (key, val) in obj {
496 if v.contains_key(key) {
497 continue;
498 }
499 let s = match val {
500 serde_json::Value::String(s) => s.clone(),
501 serde_json::Value::Number(n) => n.to_string(),
502 serde_json::Value::Bool(b) => b.to_string(),
503 _ => continue,
504 };
505 v.insert(key.clone(), s);
506 }
507 }
508 v
509}
510
511pub fn build_render_data(
514 contract: &Contract,
515 issuer: &Issuer,
516 client: &Client,
517 clause_rows: &[ContractClauseRow],
518 pack: &Pack,
519 force_draft: bool,
520 force_final: bool,
521) -> Result<ContractRenderData> {
522 let terms: serde_json::Value = serde_json::from_str(&contract.terms_json)
523 .map_err(|e| AppError::Other(format!("invalid terms_json: {e}")))?;
524 let (our_role, their_role) = party_role_labels(&contract.kind, &terms);
525
526 let vars = vars_from(contract, issuer, client, &terms, &our_role, &their_role);
527
528 let included: Vec<String> = clause_rows.iter().map(|r| r.slug.clone()).collect();
530 let mut overrides: BTreeMap<String, (Option<String>, Option<String>)> = BTreeMap::new();
531 for r in clause_rows {
532 if r.heading.is_some() || r.body.is_some() {
533 overrides.insert(r.slug.clone(), (r.heading.clone(), r.body.clone()));
534 }
535 }
536 let resolved = clauses::resolve(pack, &included, &overrides, &vars)?;
537 let render_clauses: Vec<ClauseRenderData> = resolved
538 .into_iter()
539 .enumerate()
540 .map(|(i, c)| ClauseRenderData {
541 number: (i + 1) as i64,
542 slug: c.slug,
543 heading: c.heading,
544 body: c.body,
545 })
546 .collect();
547
548 let our_legal = issuer
549 .legal_name
550 .clone()
551 .unwrap_or_else(|| issuer.name.clone());
552 let their_legal = client
553 .legal_name
554 .clone()
555 .unwrap_or_else(|| client.name.clone());
556
557 let signature = SignatureBlock {
558 our_label: our_role.clone(),
559 our_name: our_legal,
560 our_signer_name: contract.signed_by_us_name.clone(),
561 our_signer_title: contract.signed_by_us_title.clone(),
562 our_signer_date: contract.signed_by_us_at.as_deref().map(fmt_date),
563 their_label: their_role.clone(),
564 their_name: their_legal,
565 their_signer_name: contract.signed_by_them_name.clone(),
566 their_signer_title: contract.signed_by_them_title.clone(),
567 their_signer_date: contract.signed_by_them_at.as_deref().map(fmt_date),
568 };
569
570 let is_executed = matches!(
574 contract.status.as_str(),
575 "signed" | "active" | "expired" | "terminated"
576 );
577 let draft = if force_draft {
578 true
579 } else if force_final {
580 false
581 } else {
582 !is_executed
583 };
584
585 let our_party = party_display(issuer, &our_role);
586 let their_party = client_display(client, &their_role);
587 let parties_prose = vec![party_intro_prose(&our_party), party_intro_prose(&their_party)];
588 let auto = auto_default_title(&contract.kind, &issuer.name, &client.name);
590 let subtitle = if contract.title.trim() == auto.trim() {
591 None
592 } else {
593 Some(contract.title.clone())
594 };
595
596 Ok(ContractRenderData {
597 kind: contract.kind.clone(),
598 kind_label: kind_label(&contract.kind, &terms),
599 number: contract.number.clone(),
600 subtitle,
601 effective_date_display: fmt_date(&contract.effective_date),
602 end_date_display: contract.end_date.as_deref().map(fmt_date),
603 term_text: term_text(contract),
604 term_short: term_short(contract),
605 governing_law: contract.governing_law.clone(),
606 jurisdiction_phrase: jurisdiction_phrase(&contract.governing_law),
607 venue: contract.venue.clone(),
608 status: contract.status.clone(),
609 draft_watermark: draft,
610 fee_text: fee_text(contract),
611 fee_short: fee_short(contract),
612 our_party,
613 their_party,
614 parties_prose,
615 clauses: render_clauses,
616 signature,
617 logo: None, internal_notes: contract.notes.clone(),
619 })
620}
621
622pub fn render_to_pdf(
623 template: &str,
624 data: &mut ContractRenderData,
625 issuer: &Issuer,
626 out_path: &Path,
627) -> Result<()> {
628 typst_assets::ensure_extracted()?;
629 if !typst_assets::has_template(template)? {
630 return Err(AppError::InvalidInput(format!(
631 "template '{template}' not found. Run: contract template list"
632 )));
633 }
634
635 let tmp = tempfile::Builder::new()
636 .prefix("contract-cli-render-")
637 .tempdir()?;
638 let root = tmp.path();
639 copy_dir_contents(&typst_assets::project_root()?, root)?;
640
641 data.logo = stage_logo(root, issuer)?;
643
644 let json_path = root.join("shared").join("contract.json");
645 std::fs::write(&json_path, serde_json::to_vec_pretty(&data)?)?;
646
647 let template_path = root.join("templates").join(format!("{template}.typ"));
648 let mut cmd = Command::new("typst");
649 cmd.arg("compile").arg("--root").arg(root);
650 let fonts_dir = root.join("fonts");
653 if fonts_dir.is_dir() {
654 cmd.arg("--font-path").arg(&fonts_dir);
655 }
656 let status = cmd
657 .arg(&template_path)
658 .arg(out_path)
659 .status()
660 .map_err(|e| AppError::Render(format!("typst binary not found: {e}")))?;
661 if !status.success() {
662 return Err(AppError::Render(format!(
663 "typst compile exited with {}",
664 status.code().unwrap_or(-1)
665 )));
666 }
667 Ok(())
668}
669
670fn copy_dir_contents(src: &Path, dst: &Path) -> Result<()> {
671 std::fs::create_dir_all(dst)?;
672 for entry in std::fs::read_dir(src)? {
673 let entry = entry?;
674 let src_path = entry.path();
675 let dst_path = dst.join(entry.file_name());
676 if src_path.is_dir() {
677 copy_dir_contents(&src_path, &dst_path)?;
678 } else {
679 std::fs::copy(&src_path, &dst_path)?;
680 }
681 }
682 Ok(())
683}
684
685fn stage_logo(root: &Path, issuer: &Issuer) -> Result<Option<String>> {
686 let Some(src_raw) = &issuer.logo_path else {
687 return Ok(None);
688 };
689 let src_expanded = expand_tilde(src_raw);
690 let src = Path::new(&src_expanded);
691 if !src.exists() {
692 eprintln!(
693 "warning: logo '{}' not found for issuer '{}' — rendering without",
694 src.display(),
695 issuer.slug
696 );
697 return Ok(None);
698 }
699 let ext = src
700 .extension()
701 .and_then(|e| e.to_str())
702 .unwrap_or("png")
703 .to_lowercase();
704 let rel = format!("shared/logo-{}.{ext}", issuer.slug);
705 let dst = root.join(&rel);
706 if let Some(parent) = dst.parent() {
707 std::fs::create_dir_all(parent)?;
708 }
709 std::fs::copy(src, &dst)?;
710 Ok(Some(format!("/{rel}")))
711}
712
713pub fn expand_tilde(s: &str) -> String {
714 if let Some(rest) = s.strip_prefix("~/") {
715 if let Ok(home) = std::env::var("HOME") {
716 return format!("{home}/{rest}");
717 }
718 }
719 s.to_string()
720}
721
722pub fn default_output_dir() -> std::path::PathBuf {
723 let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
724 std::path::PathBuf::from(home).join("Documents").join("Contracts")
725}