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 "msa" => "Master Services Agreement".into(),
123 "sow" => "Statement of Work".into(),
124 "service" => "Service Agreement".into(),
125 other => format!("{} Agreement", capitalize(other)),
126 }
127}
128
129fn capitalize(s: &str) -> String {
130 let mut chars = s.chars();
131 chars.next().map(|c| c.to_uppercase().collect::<String>() + chars.as_str())
132 .unwrap_or_default()
133}
134
135fn party_role_labels(kind: &str, terms: &serde_json::Value) -> (String, String) {
136 match kind {
137 "consulting" => ("Consultant".into(), "Client".into()),
138 "msa" | "sow" => ("Provider".into(), "Client".into()),
139 "service" => ("Provider".into(), "Customer".into()),
140 "nda" => {
141 let mutuality = terms.get("mutuality").and_then(|v| v.as_str()).unwrap_or("mutual");
142 if mutuality == "mutual" {
143 ("Party A".into(), "Party B".into())
144 } else {
145 let disclosing = terms.get("disclosing_side").and_then(|v| v.as_str()).unwrap_or("us");
146 if disclosing == "us" {
147 ("Disclosing Party".into(), "Receiving Party".into())
148 } else {
149 ("Receiving Party".into(), "Disclosing Party".into())
150 }
151 }
152 }
153 _ => ("Party A".into(), "Party B".into()),
154 }
155}
156
157fn jurisdiction_phrase(law: &str) -> String {
158 let l = law.trim();
160 let lower = l.to_lowercase();
161 if lower.contains("singapore") {
162 "the courts of Singapore".into()
163 } else if lower.contains("england") || lower.contains("wales") || lower.contains("united kingdom") || lower == "uk" {
164 "the courts of England and Wales".into()
165 } else if lower.contains("delaware") {
166 "the state and federal courts located in Delaware".into()
167 } else if lower.contains("new york") {
168 "the state and federal courts of New York".into()
169 } else if lower.contains("hong kong") {
170 "the courts of the Hong Kong Special Administrative Region".into()
171 } else if lower.contains("germany") {
172 "the courts of Frankfurt am Main, Germany".into()
173 } else if lower.contains("france") {
174 "the courts of Paris, France".into()
175 } else {
176 format!("the courts of {}", l)
177 }
178}
179
180fn term_text(c: &Contract) -> String {
181 if let Some(end) = &c.end_date {
182 format!("until {}", fmt_date(end))
183 } else if let Some(m) = c.term_months {
184 if m % 12 == 0 {
185 let years = m / 12;
186 if years == 1 {
187 "for one year from the Effective Date".into()
188 } else {
189 format!("for {} years from the Effective Date", years)
190 }
191 } else if m == 1 {
192 "for one month from the Effective Date".into()
193 } else {
194 format!("for {} months from the Effective Date", m)
195 }
196 } else {
197 "indefinitely until terminated as provided below".into()
198 }
199}
200
201fn term_short(c: &Contract) -> String {
202 if let Some(end) = &c.end_date {
203 NaiveDate::parse_from_str(end, "%Y-%m-%d")
205 .map(|d| format!("until {}", d.format("%-d %b %Y")))
206 .unwrap_or_else(|_| format!("until {end}"))
207 } else if let Some(m) = c.term_months {
208 if m % 12 == 0 {
209 let y = m / 12;
210 if y == 1 { "1 year".into() } else { format!("{y} years") }
211 } else if m == 1 {
212 "1 month".into()
213 } else {
214 format!("{m} months")
215 }
216 } else {
217 "Indefinite".into()
218 }
219}
220
221fn fee_short(c: &Contract) -> Option<String> {
222 let kind = c.fee_type.as_deref()?;
223 let amt = c.fee_amount_minor?;
224 let cur = c.fee_currency.clone().unwrap_or_default();
225 let symbol = finance_core::money::currency_symbol(&cur);
226 let amt_str = finance_core::money::MinorUnits(amt).format_number();
227 let amt_display = if symbol.is_empty() {
228 format!("{} {}", amt_str, cur)
229 } else {
230 format!("{}{}", symbol, amt_str)
231 };
232 Some(match kind {
233 "fixed" => format!("{amt_display} fixed"),
234 "hourly" => format!("{amt_display}/hr"),
235 "daily" => format!("{amt_display}/day"),
236 "retainer" => format!("{amt_display}/month"),
237 _ => amt_display,
238 })
239}
240
241fn ip_assignment_text(terms: &serde_json::Value) -> String {
242 let mode = terms.get("ip_assignment").and_then(|v| v.as_str()).unwrap_or("client");
243 match mode {
244 "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(),
245 "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(),
246 "shared" => "The parties jointly own the deliverables. Each party may use them for any purpose without accounting to the other.".into(),
247 _ => "All deliverables produced specifically for the Client under this engagement belong to the Client, on payment of the relevant fees.".into(),
248 }
249}
250
251fn fee_text(c: &Contract) -> Option<String> {
252 let kind = c.fee_type.as_deref()?;
253 let amt = c.fee_amount_minor?;
254 let cur = c.fee_currency.clone().unwrap_or_default();
255 let symbol = finance_core::money::currency_symbol(&cur);
256 let amt_str = finance_core::money::MinorUnits(amt).format_number();
257 let amt_display = if symbol.is_empty() {
258 format!("{} {}", amt_str, cur)
259 } else {
260 format!("{}{}", symbol, amt_str)
261 };
262 let sched_phrase = match c.fee_schedule.as_deref() {
263 Some("on-completion") => ", payable on completion of the Services",
264 Some("on-milestone") => ", payable on completion of each milestone",
265 Some("monthly") => ", invoiced monthly in arrears",
266 Some("upon-invoice") => ", invoiced as work is performed",
267 _ => "",
268 };
269 Some(match kind {
270 "fixed" => format!("a fixed fee of {amt_display}{sched_phrase}"),
271 "hourly" => format!("an hourly rate of {amt_display}/hour{sched_phrase}"),
272 "daily" => format!("a daily rate of {amt_display}/day{sched_phrase}"),
273 "retainer" => format!("a monthly retainer of {amt_display}{sched_phrase}"),
274 _ => format!("{amt_display}{sched_phrase}"),
275 })
276}
277
278fn deliverables_block(terms: &serde_json::Value) -> String {
279 let items: Vec<String> = terms
280 .get("deliverables")
281 .and_then(|v| v.as_array())
282 .map(|arr| {
283 arr.iter()
284 .filter_map(|x| x.as_str().map(str::to_string))
285 .collect()
286 })
287 .unwrap_or_default();
288 if items.is_empty() {
289 "_To be agreed in writing by the parties._".into()
290 } else {
291 items
292 .into_iter()
293 .map(|s| format!("- {s}"))
294 .collect::<Vec<_>>()
295 .join("\n")
296 }
297}
298
299fn party_intro_prose(p: &PartyData) -> String {
305 let legal = p.legal_name.clone().unwrap_or_else(|| p.display_name.clone());
306 let name_upper = legal.to_uppercase();
307 let addr = p.address.join(", ");
308 let looks_like_company = looks_like_company_name(&legal);
309 let mut qualifier = String::new();
310 if let Some(j) = &p.jurisdiction {
311 if looks_like_company {
312 qualifier.push_str(&format!(", a company incorporated in {j}"));
313 if let Some(co) = &p.company_no {
314 qualifier.push_str(&format!(" (Co. No. {co})"));
315 } else {
316 qualifier.push(')');
317 qualifier.pop();
320 }
321 }
322 } else if let Some(co) = &p.company_no {
323 qualifier.push_str(&format!(", company no. {co}"));
325 }
326 format!("*{name_upper}* of {addr}{qualifier} (the \"{role}\")", role = p.role_label)
328}
329
330fn looks_like_company_name(s: &str) -> bool {
331 let lower = s.to_lowercase();
332 [
333 " ltd", " limited", " inc", " incorporated", " corp", " corporation",
334 " pte", " llc", " llp", " plc", " gmbh", " ag", " sa", " s.a.",
335 " sas", " bv", " nv", " pty",
336 ]
337 .iter()
338 .any(|s| lower.contains(s))
339}
340
341fn auto_default_title(kind: &str, issuer_name: &str, client_name: &str) -> String {
345 match kind {
346 "nda" => format!("NDA — {issuer_name} & {client_name}"),
347 "consulting" => format!("Consulting Agreement — {issuer_name} × {client_name}"),
348 "msa" => format!("Master Services Agreement — {issuer_name} & {client_name}"),
349 "sow" => format!("Statement of Work — {issuer_name} × {client_name}"),
350 "service" => format!("Service Agreement — {issuer_name} for {client_name}"),
351 _ => format!("Agreement — {issuer_name} & {client_name}"),
352 }
353}
354
355fn party_display(issuer: &Issuer, role_label: &str) -> PartyData {
356 PartyData {
357 role_label: role_label.to_string(),
358 display_name: issuer.name.clone(),
359 legal_name: issuer.legal_name.clone(),
360 company_no: issuer.company_no.clone(),
361 address: issuer.address.clone(),
362 jurisdiction: Some(issuer.jurisdiction.profile().country.to_string()),
363 email: issuer.email.clone(),
364 attn: None,
365 }
366}
367
368fn client_display(client: &Client, role_label: &str) -> PartyData {
369 PartyData {
370 role_label: role_label.to_string(),
371 display_name: client.name.clone(),
372 legal_name: client.legal_name.clone(),
373 company_no: client.company_no.clone(),
374 address: client.address.clone(),
375 jurisdiction: client.legal_jurisdiction.clone(),
376 email: client.email.clone(),
377 attn: client.attn.clone(),
378 }
379}
380
381fn vars_from(
382 contract: &Contract,
383 issuer: &Issuer,
384 client: &Client,
385 terms: &serde_json::Value,
386 our_role: &str,
387 their_role: &str,
388) -> BTreeMap<String, String> {
389 let mut v = BTreeMap::new();
390 let our_legal = issuer.legal_name.clone().unwrap_or_else(|| issuer.name.clone());
391 let their_legal = client.legal_name.clone().unwrap_or_else(|| client.name.clone());
392 v.insert("our_name".into(), issuer.name.clone());
393 v.insert("our_legal_name".into(), our_legal);
394 v.insert("our_role".into(), our_role.to_string());
395 v.insert("their_name".into(), client.name.clone());
396 v.insert("their_legal_name".into(), their_legal);
397 v.insert("their_role".into(), their_role.to_string());
398 v.insert("title".into(), contract.title.clone());
399 v.insert("number".into(), contract.number.clone());
400 v.insert("effective_date".into(), fmt_date(&contract.effective_date));
401 v.insert(
402 "end_date".into(),
403 contract.end_date.as_deref().map(fmt_date).unwrap_or_default(),
404 );
405 v.insert("term_text".into(), term_text(contract));
406 v.insert("governing_law".into(), contract.governing_law.clone());
407 v.insert(
408 "jurisdiction_phrase".into(),
409 jurisdiction_phrase(&contract.governing_law),
410 );
411 v.insert(
412 "venue".into(),
413 contract.venue.clone().unwrap_or_default(),
414 );
415 v.insert(
417 "purpose".into(),
418 terms
419 .get("purpose")
420 .and_then(|x| x.as_str())
421 .unwrap_or("the matters described in the title")
422 .to_string(),
423 );
424 v.insert(
425 "mutuality".into(),
426 terms
427 .get("mutuality")
428 .and_then(|x| x.as_str())
429 .unwrap_or("mutual")
430 .to_string(),
431 );
432 v.insert(
433 "confidentiality_years".into(),
434 terms
435 .get("confidentiality_years")
436 .and_then(|x| x.as_i64())
437 .map(|n| n.to_string())
438 .unwrap_or_else(|| "3".into()),
439 );
440 v.insert(
441 "termination_notice_days".into(),
442 terms
443 .get("termination_notice_days")
444 .and_then(|x| x.as_i64())
445 .or_else(|| {
446 contract
447 .terms_json
448 .parse::<serde_json::Value>()
449 .ok()
450 .and_then(|t| t.get("termination_notice_days").and_then(|x| x.as_i64()))
451 })
452 .map(|n| n.to_string())
453 .unwrap_or_else(|| "30".into()),
454 );
455 v.insert(
457 "deliverables_block".into(),
458 deliverables_block(terms),
459 );
460 v.insert("ip_assignment_text".into(), ip_assignment_text(terms));
461 v.insert(
462 "fee_text".into(),
463 fee_text(contract).unwrap_or_else(|| "as separately agreed in writing".into()),
464 );
465 v
466}
467
468pub fn build_render_data(
471 contract: &Contract,
472 issuer: &Issuer,
473 client: &Client,
474 clause_rows: &[ContractClauseRow],
475 pack: &Pack,
476 force_draft: bool,
477 force_final: bool,
478) -> Result<ContractRenderData> {
479 let terms: serde_json::Value = serde_json::from_str(&contract.terms_json)
480 .map_err(|e| AppError::Other(format!("invalid terms_json: {e}")))?;
481 let (our_role, their_role) = party_role_labels(&contract.kind, &terms);
482
483 let vars = vars_from(contract, issuer, client, &terms, &our_role, &their_role);
484
485 let included: Vec<String> = clause_rows.iter().map(|r| r.slug.clone()).collect();
487 let mut overrides: BTreeMap<String, (Option<String>, Option<String>)> = BTreeMap::new();
488 for r in clause_rows {
489 if r.heading.is_some() || r.body.is_some() {
490 overrides.insert(r.slug.clone(), (r.heading.clone(), r.body.clone()));
491 }
492 }
493 let resolved = clauses::resolve(pack, &included, &overrides, &vars)?;
494 let render_clauses: Vec<ClauseRenderData> = resolved
495 .into_iter()
496 .enumerate()
497 .map(|(i, c)| ClauseRenderData {
498 number: (i + 1) as i64,
499 slug: c.slug,
500 heading: c.heading,
501 body: c.body,
502 })
503 .collect();
504
505 let our_legal = issuer
506 .legal_name
507 .clone()
508 .unwrap_or_else(|| issuer.name.clone());
509 let their_legal = client
510 .legal_name
511 .clone()
512 .unwrap_or_else(|| client.name.clone());
513
514 let signature = SignatureBlock {
515 our_label: our_role.clone(),
516 our_name: our_legal,
517 our_signer_name: contract.signed_by_us_name.clone(),
518 our_signer_title: contract.signed_by_us_title.clone(),
519 our_signer_date: contract.signed_by_us_at.as_deref().map(fmt_date),
520 their_label: their_role.clone(),
521 their_name: their_legal,
522 their_signer_name: contract.signed_by_them_name.clone(),
523 their_signer_title: contract.signed_by_them_title.clone(),
524 their_signer_date: contract.signed_by_them_at.as_deref().map(fmt_date),
525 };
526
527 let is_executed = matches!(contract.status.as_str(), "signed" | "active");
530 let draft = if force_draft {
531 true
532 } else if force_final {
533 false
534 } else {
535 !is_executed
536 };
537
538 let our_party = party_display(issuer, &our_role);
539 let their_party = client_display(client, &their_role);
540 let parties_prose = vec![party_intro_prose(&our_party), party_intro_prose(&their_party)];
541 let auto = auto_default_title(&contract.kind, &issuer.name, &client.name);
543 let subtitle = if contract.title.trim() == auto.trim() {
544 None
545 } else {
546 Some(contract.title.clone())
547 };
548
549 Ok(ContractRenderData {
550 kind: contract.kind.clone(),
551 kind_label: kind_label(&contract.kind, &terms),
552 number: contract.number.clone(),
553 subtitle,
554 effective_date_display: fmt_date(&contract.effective_date),
555 end_date_display: contract.end_date.as_deref().map(fmt_date),
556 term_text: term_text(contract),
557 term_short: term_short(contract),
558 governing_law: contract.governing_law.clone(),
559 jurisdiction_phrase: jurisdiction_phrase(&contract.governing_law),
560 venue: contract.venue.clone(),
561 status: contract.status.clone(),
562 draft_watermark: draft,
563 fee_text: fee_text(contract),
564 fee_short: fee_short(contract),
565 our_party,
566 their_party,
567 parties_prose,
568 clauses: render_clauses,
569 signature,
570 logo: None, internal_notes: contract.notes.clone(),
572 })
573}
574
575pub fn render_to_pdf(
576 template: &str,
577 data: &mut ContractRenderData,
578 issuer: &Issuer,
579 out_path: &Path,
580) -> Result<()> {
581 typst_assets::ensure_extracted()?;
582 if !typst_assets::has_template(template)? {
583 return Err(AppError::InvalidInput(format!(
584 "template '{template}' not found. Run: contract template list"
585 )));
586 }
587
588 let tmp = tempfile::Builder::new()
589 .prefix("contract-cli-render-")
590 .tempdir()?;
591 let root = tmp.path();
592 copy_dir_contents(&typst_assets::project_root()?, root)?;
593
594 data.logo = stage_logo(root, issuer)?;
596
597 let json_path = root.join("shared").join("contract.json");
598 std::fs::write(&json_path, serde_json::to_vec_pretty(&data)?)?;
599
600 let template_path = root.join("templates").join(format!("{template}.typ"));
601 let status = Command::new("typst")
602 .arg("compile")
603 .arg("--root")
604 .arg(root)
605 .arg(&template_path)
606 .arg(out_path)
607 .status()
608 .map_err(|e| AppError::Render(format!("typst binary not found: {e}")))?;
609 if !status.success() {
610 return Err(AppError::Render(format!(
611 "typst compile exited with {}",
612 status.code().unwrap_or(-1)
613 )));
614 }
615 Ok(())
616}
617
618fn copy_dir_contents(src: &Path, dst: &Path) -> Result<()> {
619 std::fs::create_dir_all(dst)?;
620 for entry in std::fs::read_dir(src)? {
621 let entry = entry?;
622 let src_path = entry.path();
623 let dst_path = dst.join(entry.file_name());
624 if src_path.is_dir() {
625 copy_dir_contents(&src_path, &dst_path)?;
626 } else {
627 std::fs::copy(&src_path, &dst_path)?;
628 }
629 }
630 Ok(())
631}
632
633fn stage_logo(root: &Path, issuer: &Issuer) -> Result<Option<String>> {
634 let Some(src_raw) = &issuer.logo_path else {
635 return Ok(None);
636 };
637 let src_expanded = expand_tilde(src_raw);
638 let src = Path::new(&src_expanded);
639 if !src.exists() {
640 eprintln!(
641 "warning: logo '{}' not found for issuer '{}' — rendering without",
642 src.display(),
643 issuer.slug
644 );
645 return Ok(None);
646 }
647 let ext = src
648 .extension()
649 .and_then(|e| e.to_str())
650 .unwrap_or("png")
651 .to_lowercase();
652 let rel = format!("shared/logo-{}.{ext}", issuer.slug);
653 let dst = root.join(&rel);
654 if let Some(parent) = dst.parent() {
655 std::fs::create_dir_all(parent)?;
656 }
657 std::fs::copy(src, &dst)?;
658 Ok(Some(format!("/{rel}")))
659}
660
661pub fn expand_tilde(s: &str) -> String {
662 if let Some(rest) = s.strip_prefix("~/") {
663 if let Ok(home) = std::env::var("HOME") {
664 return format!("{home}/{rest}");
665 }
666 }
667 s.to_string()
668}
669
670pub fn default_output_dir() -> std::path::PathBuf {
671 let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
672 std::path::PathBuf::from(home).join("Documents").join("Contracts")
673}