Skip to main content

contract_cli/commands/
template.rs

1use chrono::Utc;
2use std::path::PathBuf;
3
4use crate::cli::TemplateCmd;
5use crate::clauses;
6use crate::db::{Client, Contract, ContractClauseRow, Issuer};
7use crate::error::Result;
8use crate::output::{print_success, Ctx};
9use crate::render;
10use crate::tax::Jurisdiction;
11use crate::typst_assets;
12
13pub fn run(cmd: TemplateCmd, ctx: Ctx) -> Result<()> {
14    match cmd {
15        TemplateCmd::List => {
16            let names = typst_assets::list_templates()?;
17            print_success(ctx, &names, |ns| {
18                if ns.is_empty() {
19                    println!("(no templates)");
20                } else {
21                    for n in ns {
22                        println!("  {n}");
23                    }
24                }
25            });
26            Ok(())
27        }
28        TemplateCmd::Preview { name, kind, out } => {
29            let issuer = sample_issuer();
30            let client = sample_client();
31            let contract = sample_contract(&kind);
32            let pack = clauses::load_pack(&kind, &contract.clause_pack)?;
33            let clause_rows: Vec<ContractClauseRow> = pack
34                .pack
35                .default_clauses
36                .iter()
37                .enumerate()
38                .map(|(i, slug)| ContractClauseRow {
39                    id: 0,
40                    contract_id: 0,
41                    position: i as i64,
42                    slug: slug.clone(),
43                    heading: None,
44                    body: None,
45                })
46                .collect();
47            let mut data =
48                render::build_render_data(&contract, &issuer, &client, &clause_rows, &pack, false, true)?;
49            let out_path =
50                PathBuf::from(out.unwrap_or_else(|| format!("preview-{name}-{kind}.pdf")));
51            render::render_to_pdf(&name, &mut data, &issuer, &out_path)?;
52            print_success(ctx, &out_path.display().to_string(), |p| {
53                println!("preview → {p}");
54            });
55            Ok(())
56        }
57    }
58}
59
60fn sample_issuer() -> Issuer {
61    Issuer {
62        id: 0,
63        slug: "acme".into(),
64        name: "Acme Studio".into(),
65        legal_name: Some("Acme Studio Pte. Ltd.".into()),
66        jurisdiction: Jurisdiction::Sg,
67        tax_registered: false,
68        tax_id: None,
69        company_no: Some("202312345A".into()),
70        tagline: None,
71        address: vec![
72            "1 Marina Bay".into(),
73            "Singapore 018989".into(),
74        ],
75        email: Some("hello@acme.example".into()),
76        phone: None,
77        bank_details: None,
78        default_template: "helvetica-nera".into(),
79        currency: None,
80        symbol: None,
81        number_format: "{issuer}-{year}-{seq:04}".into(),
82        logo_path: None,
83        default_output_dir: None,
84        default_notes: None,
85    }
86}
87
88fn sample_client() -> Client {
89    Client {
90        id: 0,
91        slug: "meridian".into(),
92        name: "Meridian & Co.".into(),
93        legal_name: Some("Meridian & Co. Pty Ltd".into()),
94        company_no: Some("ACN 600 123 456".into()),
95        legal_jurisdiction: Some("Victoria, Australia".into()),
96        attn: Some("Sophie Lin, Head of Marketing".into()),
97        country: Some("AU".into()),
98        tax_id: None,
99        address: vec![
100            "401 Collins Street".into(),
101            "Melbourne VIC 3000".into(),
102            "Australia".into(),
103        ],
104        email: Some("sophie@meridian.example".into()),
105        notes: None,
106        default_issuer_slug: None,
107        default_template: None,
108    }
109}
110
111fn sample_contract(kind: &str) -> Contract {
112    let today = Utc::now().date_naive().format("%Y-%m-%d").to_string();
113    let terms = match kind {
114        "nda" => serde_json::json!({
115            "mutuality": "mutual",
116            "disclosing_side": "both",
117            "purpose": "a potential collaboration on a longevity research project",
118            "confidentiality_years": 3,
119        }),
120        "consulting" => serde_json::json!({
121            "purpose": "the design and delivery of a customer-facing dashboard for the Client's flagship product",
122            "deliverables": [
123                "Discovery interviews and a one-page strategy memo",
124                "Three rounds of high-fidelity Figma designs",
125                "Production-ready front-end implementation in React",
126                "One handover session with the Client's engineering team",
127            ],
128            "ip_assignment": "client",
129            "confidentiality_years": 3,
130            "termination_notice_days": 30,
131        }),
132        "msa" => serde_json::json!({
133            "ip_assignment": "client",
134            "confidentiality_years": 3,
135            "termination_notice_days": 30,
136        }),
137        "sow" => serde_json::json!({
138            "purpose": "Implementation of the customer dashboard described in the kick-off memo dated 2026-04-01.",
139            "deliverables": [
140                "Functional prototype by week 4",
141                "Production deployment by week 10",
142            ],
143        }),
144        "service" => serde_json::json!({
145            "purpose": "managed hosting and monthly performance reviews for the Customer's web application",
146            "deliverables": [
147                "24/7 monitoring of production endpoints",
148                "Monthly performance review and recommendations",
149            ],
150            "termination_notice_days": 60,
151        }),
152        _ => serde_json::json!({}),
153    };
154    let (fee_type, fee_minor, fee_cur, fee_sched) = match kind {
155        "consulting" => (Some("fixed".into()), Some(84_000_00i64), Some("SGD".into()), Some("on-completion".into())),
156        "msa" => (None, None, None, None),
157        "sow" => (Some("fixed".into()), Some(34_000_00i64), Some("SGD".into()), Some("on-milestone".into())),
158        "service" => (Some("retainer".into()), Some(5_000_00i64), Some("SGD".into()), Some("monthly".into())),
159        _ => (None, None, None, None),
160    };
161    let title = match kind {
162        "nda" => "Mutual NDA — Acme × Meridian".into(),
163        "consulting" => "Customer Dashboard — Consulting Engagement".into(),
164        "msa" => "Master Services Agreement — Acme × Meridian".into(),
165        "sow" => "SOW #1 — Customer Dashboard Implementation".into(),
166        "service" => "Managed Hosting & Performance Reviews".into(),
167        _ => format!("Sample {kind} agreement"),
168    };
169    Contract {
170        id: 0,
171        number: format!("{}-acme-2026-0001", prefix_for(kind)),
172        kind: kind.to_string(),
173        issuer_id: 0,
174        client_id: 0,
175        title,
176        effective_date: today,
177        end_date: None,
178        term_months: Some(12),
179        governing_law: "Singapore".into(),
180        venue: None,
181        status: "draft".into(),
182        sent_at: None,
183        signed_at: None,
184        terminated_at: None,
185        notes: None,
186        fee_type,
187        fee_amount_minor: fee_minor,
188        fee_currency: fee_cur,
189        fee_schedule: fee_sched,
190        terms_json: terms.to_string(),
191        clause_pack: "standard".into(),
192        clause_pack_version: "1.0".into(),
193        default_template: None,
194        signed_by_us_name: None,
195        signed_by_us_title: None,
196        signed_by_us_at: None,
197        signed_by_them_name: None,
198        signed_by_them_title: None,
199        signed_by_them_at: None,
200        created_at: String::new(),
201        updated_at: String::new(),
202    }
203}
204
205fn prefix_for(kind: &str) -> &'static str {
206    match kind {
207        "consulting" => "CTR",
208        "nda" => "NDA",
209        "msa" => "MSA",
210        "sow" => "SOW",
211        "service" => "SVC",
212        _ => "DOC",
213    }
214}