Skip to main content

contract_cli/
db.rs

1// ═══════════════════════════════════════════════════════════════════════════
2// Database layer — operates over the shared finance-core SQLite.
3//
4// Tables read/written: issuers (shared), clients (shared, w/ V7 legal fields),
5// contracts (V7), contract_clauses (V7), number_series (shared).
6// ═══════════════════════════════════════════════════════════════════════════
7
8use rusqlite::{params, Connection, OptionalExtension};
9use serde::{Deserialize, Serialize};
10use std::path::Path;
11
12use crate::error::{AppError, Result};
13use crate::tax::Jurisdiction;
14
15pub use finance_core::entity::Issuer;
16
17pub fn open() -> Result<Connection> {
18    let paths = finance_core::paths::Paths::resolve()?;
19    Ok(finance_core::db::open(&paths)?)
20}
21
22pub fn open_at(path: &Path) -> Result<Connection> {
23    Ok(finance_core::db::open_at(path)?)
24}
25
26// ─── Issuers (shared with invoice-cli) ────────────────────────────────────
27
28fn addr_to_text(lines: &[String]) -> String {
29    lines.join("\n")
30}
31fn text_to_addr(s: &str) -> Vec<String> {
32    s.split('\n').map(|l| l.to_string()).collect()
33}
34
35pub fn issuer_create(conn: &Connection, issuer: &Issuer) -> Result<i64> {
36    conn.execute(
37        "INSERT INTO issuers (slug, name, legal_name, jurisdiction, tax_registered,
38                              tax_id, company_no, tagline, address, email, phone,
39                              bank_details, default_template,
40                              currency, symbol, number_format, logo_path,
41                              default_output_dir, default_notes)
42         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
43        params![
44            issuer.slug,
45            issuer.name,
46            issuer.legal_name,
47            issuer.jurisdiction.as_str(),
48            issuer.tax_registered as i32,
49            issuer.tax_id,
50            issuer.company_no,
51            issuer.tagline,
52            addr_to_text(&issuer.address),
53            issuer.email,
54            issuer.phone,
55            issuer.bank_details,
56            issuer.default_template,
57            issuer.currency,
58            issuer.symbol,
59            issuer.number_format,
60            issuer.logo_path,
61            issuer.default_output_dir,
62            issuer.default_notes,
63        ],
64    )?;
65    Ok(conn.last_insert_rowid())
66}
67
68pub fn issuer_list(conn: &Connection) -> Result<Vec<Issuer>> {
69    let mut stmt = conn.prepare(
70        "SELECT id, slug, name, legal_name, jurisdiction, tax_registered,
71                tax_id, company_no, tagline, address, email, phone,
72                bank_details, default_template,
73                currency, symbol, number_format, logo_path,
74                default_output_dir, default_notes
75         FROM issuers ORDER BY slug",
76    )?;
77    let rows = stmt
78        .query_map([], |row| {
79            Ok(Issuer {
80                id: row.get(0)?,
81                slug: row.get(1)?,
82                name: row.get(2)?,
83                legal_name: row.get(3)?,
84                jurisdiction: Jurisdiction::from_str(&row.get::<_, String>(4)?)
85                    .unwrap_or(Jurisdiction::Custom),
86                tax_registered: row.get::<_, i32>(5)? != 0,
87                tax_id: row.get(6)?,
88                company_no: row.get(7)?,
89                tagline: row.get(8)?,
90                address: text_to_addr(&row.get::<_, String>(9)?),
91                email: row.get(10)?,
92                phone: row.get(11)?,
93                bank_details: row.get(12)?,
94                default_template: row.get(13)?,
95                currency: row.get(14)?,
96                symbol: row.get(15)?,
97                number_format: row.get(16)?,
98                logo_path: row.get(17)?,
99                default_output_dir: row.get(18)?,
100                default_notes: row.get(19)?,
101            })
102        })?
103        .collect::<std::result::Result<Vec<_>, _>>()?;
104    Ok(rows)
105}
106
107pub fn issuer_by_slug(conn: &Connection, slug: &str) -> Result<Issuer> {
108    for i in issuer_list(conn)? {
109        if i.slug == slug {
110            return Ok(i);
111        }
112    }
113    let lower = slug.to_lowercase();
114    let matches: Vec<Issuer> = issuer_list(conn)?
115        .into_iter()
116        .filter(|i| i.slug.contains(slug) || i.name.to_lowercase().contains(&lower))
117        .collect();
118    match matches.len() {
119        0 => Err(AppError::NotFound(format!("issuer '{slug}'"))),
120        1 => Ok(matches.into_iter().next().unwrap()),
121        _ => Err(AppError::Ambiguous(format!(
122            "issuer '{slug}' matches {}",
123            matches
124                .iter()
125                .map(|m| m.slug.as_str())
126                .collect::<Vec<_>>()
127                .join(", ")
128        ))),
129    }
130}
131
132pub fn issuer_update(conn: &Connection, issuer: &Issuer) -> Result<()> {
133    let affected = conn.execute(
134        "UPDATE issuers SET
135             name = ?1, legal_name = ?2, jurisdiction = ?3, tax_registered = ?4,
136             tax_id = ?5, company_no = ?6, tagline = ?7, address = ?8,
137             email = ?9, phone = ?10, bank_details = ?11, default_template = ?12,
138             currency = ?13, symbol = ?14, number_format = ?15, logo_path = ?16,
139             default_output_dir = ?17, default_notes = ?18
140         WHERE slug = ?19",
141        params![
142            issuer.name,
143            issuer.legal_name,
144            issuer.jurisdiction.as_str(),
145            issuer.tax_registered as i32,
146            issuer.tax_id,
147            issuer.company_no,
148            issuer.tagline,
149            addr_to_text(&issuer.address),
150            issuer.email,
151            issuer.phone,
152            issuer.bank_details,
153            issuer.default_template,
154            issuer.currency,
155            issuer.symbol,
156            issuer.number_format,
157            issuer.logo_path,
158            issuer.default_output_dir,
159            issuer.default_notes,
160            issuer.slug,
161        ],
162    )?;
163    if affected == 0 {
164        return Err(AppError::NotFound(format!("issuer '{}'", issuer.slug)));
165    }
166    Ok(())
167}
168
169pub fn issuer_delete(conn: &Connection, slug: &str) -> Result<()> {
170    let affected = conn.execute("DELETE FROM issuers WHERE slug = ?1", params![slug])?;
171    if affected == 0 {
172        return Err(AppError::NotFound(format!("issuer '{slug}'")));
173    }
174    Ok(())
175}
176
177// ─── Clients (shared, with V7 legal fields) ───────────────────────────────
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct Client {
181    pub id: i64,
182    pub slug: String,
183    pub name: String,
184    pub legal_name: Option<String>,
185    pub company_no: Option<String>,
186    pub legal_jurisdiction: Option<String>,
187    pub attn: Option<String>,
188    pub country: Option<String>,
189    pub tax_id: Option<String>,
190    pub address: Vec<String>,
191    pub email: Option<String>,
192    pub notes: Option<String>,
193    pub default_issuer_slug: Option<String>,
194    pub default_template: Option<String>,
195}
196
197pub fn client_create(conn: &Connection, c: &Client) -> Result<i64> {
198    conn.execute(
199        "INSERT INTO clients (slug, name, attn, country, tax_id, address, email, notes,
200                              default_issuer_slug, default_template,
201                              legal_name, company_no, legal_jurisdiction)
202         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
203        params![
204            c.slug,
205            c.name,
206            c.attn,
207            c.country,
208            c.tax_id,
209            addr_to_text(&c.address),
210            c.email,
211            c.notes,
212            c.default_issuer_slug,
213            c.default_template,
214            c.legal_name,
215            c.company_no,
216            c.legal_jurisdiction,
217        ],
218    )?;
219    Ok(conn.last_insert_rowid())
220}
221
222pub fn client_list(conn: &Connection) -> Result<Vec<Client>> {
223    let mut stmt = conn.prepare(
224        "SELECT id, slug, name, attn, country, tax_id, address, email, notes,
225                default_issuer_slug, default_template,
226                legal_name, company_no, legal_jurisdiction
227         FROM clients ORDER BY slug",
228    )?;
229    let rows = stmt
230        .query_map([], |row| {
231            Ok(Client {
232                id: row.get(0)?,
233                slug: row.get(1)?,
234                name: row.get(2)?,
235                attn: row.get(3)?,
236                country: row.get(4)?,
237                tax_id: row.get(5)?,
238                address: text_to_addr(&row.get::<_, String>(6)?),
239                email: row.get(7)?,
240                notes: row.get(8)?,
241                default_issuer_slug: row.get(9)?,
242                default_template: row.get(10)?,
243                legal_name: row.get(11)?,
244                company_no: row.get(12)?,
245                legal_jurisdiction: row.get(13)?,
246            })
247        })?
248        .collect::<std::result::Result<Vec<_>, _>>()?;
249    Ok(rows)
250}
251
252pub fn client_by_slug(conn: &Connection, slug: &str) -> Result<Client> {
253    for c in client_list(conn)? {
254        if c.slug == slug {
255            return Ok(c);
256        }
257    }
258    let lower = slug.to_lowercase();
259    let matches: Vec<Client> = client_list(conn)?
260        .into_iter()
261        .filter(|c| c.slug.contains(slug) || c.name.to_lowercase().contains(&lower))
262        .collect();
263    match matches.len() {
264        0 => Err(AppError::NotFound(format!("client '{slug}'"))),
265        1 => Ok(matches.into_iter().next().unwrap()),
266        _ => Err(AppError::Ambiguous(format!(
267            "client '{slug}' matches {}",
268            matches
269                .iter()
270                .map(|m| m.slug.as_str())
271                .collect::<Vec<_>>()
272                .join(", ")
273        ))),
274    }
275}
276
277pub fn client_update(conn: &Connection, c: &Client) -> Result<()> {
278    let affected = conn.execute(
279        "UPDATE clients SET
280             name = ?1, attn = ?2, country = ?3, tax_id = ?4, address = ?5,
281             email = ?6, notes = ?7, default_issuer_slug = ?8, default_template = ?9,
282             legal_name = ?10, company_no = ?11, legal_jurisdiction = ?12
283         WHERE slug = ?13",
284        params![
285            c.name,
286            c.attn,
287            c.country,
288            c.tax_id,
289            addr_to_text(&c.address),
290            c.email,
291            c.notes,
292            c.default_issuer_slug,
293            c.default_template,
294            c.legal_name,
295            c.company_no,
296            c.legal_jurisdiction,
297            c.slug,
298        ],
299    )?;
300    if affected == 0 {
301        return Err(AppError::NotFound(format!("client '{}'", c.slug)));
302    }
303    Ok(())
304}
305
306pub fn client_delete(conn: &Connection, slug: &str) -> Result<()> {
307    let affected = conn.execute("DELETE FROM clients WHERE slug = ?1", params![slug])?;
308    if affected == 0 {
309        return Err(AppError::NotFound(format!("client '{slug}'")));
310    }
311    Ok(())
312}
313
314// ─── Contracts ────────────────────────────────────────────────────────────
315
316#[derive(Debug, Clone, Serialize, Deserialize)]
317pub struct Contract {
318    pub id: i64,
319    pub number: String,
320    pub kind: String,
321    pub issuer_id: i64,
322    pub client_id: i64,
323    pub title: String,
324    pub effective_date: String,
325    pub end_date: Option<String>,
326    pub term_months: Option<i64>,
327    pub governing_law: String,
328    pub venue: Option<String>,
329    pub status: String,
330    pub sent_at: Option<String>,
331    pub signed_at: Option<String>,
332    pub terminated_at: Option<String>,
333    pub notes: Option<String>,
334    pub fee_type: Option<String>,
335    pub fee_amount_minor: Option<i64>,
336    pub fee_currency: Option<String>,
337    pub fee_schedule: Option<String>,
338    pub terms_json: String,
339    pub clause_pack: String,
340    pub clause_pack_version: String,
341    pub default_template: Option<String>,
342    pub signed_by_us_name: Option<String>,
343    pub signed_by_us_title: Option<String>,
344    pub signed_by_us_at: Option<String>,
345    pub signed_by_them_name: Option<String>,
346    pub signed_by_them_title: Option<String>,
347    pub signed_by_them_at: Option<String>,
348    pub created_at: String,
349    pub updated_at: String,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct ContractClauseRow {
354    pub id: i64,
355    pub contract_id: i64,
356    pub position: i64,
357    pub slug: String,
358    pub heading: Option<String>,
359    pub body: Option<String>,
360}
361
362const SELECT_CONTRACT: &str = "SELECT c.id, c.number, c.kind, c.issuer_id, c.client_id, c.title,
363        c.effective_date, c.end_date, c.term_months, c.governing_law, c.venue,
364        c.status, c.sent_at, c.signed_at, c.terminated_at, c.notes,
365        c.fee_type, c.fee_amount_minor, c.fee_currency, c.fee_schedule,
366        c.terms_json, c.clause_pack, c.clause_pack_version, c.default_template,
367        c.signed_by_us_name, c.signed_by_us_title, c.signed_by_us_at,
368        c.signed_by_them_name, c.signed_by_them_title, c.signed_by_them_at,
369        c.created_at, c.updated_at";
370
371fn row_to_contract(row: &rusqlite::Row) -> rusqlite::Result<Contract> {
372    Ok(Contract {
373        id: row.get(0)?,
374        number: row.get(1)?,
375        kind: row.get(2)?,
376        issuer_id: row.get(3)?,
377        client_id: row.get(4)?,
378        title: row.get(5)?,
379        effective_date: row.get(6)?,
380        end_date: row.get(7)?,
381        term_months: row.get(8)?,
382        governing_law: row.get(9)?,
383        venue: row.get(10)?,
384        status: row.get(11)?,
385        sent_at: row.get(12)?,
386        signed_at: row.get(13)?,
387        terminated_at: row.get(14)?,
388        notes: row.get(15)?,
389        fee_type: row.get(16)?,
390        fee_amount_minor: row.get(17)?,
391        fee_currency: row.get(18)?,
392        fee_schedule: row.get(19)?,
393        terms_json: row.get(20)?,
394        clause_pack: row.get(21)?,
395        clause_pack_version: row.get(22)?,
396        default_template: row.get(23)?,
397        signed_by_us_name: row.get(24)?,
398        signed_by_us_title: row.get(25)?,
399        signed_by_us_at: row.get(26)?,
400        signed_by_them_name: row.get(27)?,
401        signed_by_them_title: row.get(28)?,
402        signed_by_them_at: row.get(29)?,
403        created_at: row.get(30)?,
404        updated_at: row.get(31)?,
405    })
406}
407
408pub fn contract_create(conn: &mut Connection, c: &Contract, clauses: &[ContractClauseRow]) -> Result<i64> {
409    let tx = conn.transaction()?;
410    // Explicit RFC3339 stamps — the column DEFAULT CURRENT_TIMESTAMP emits a
411    // different format ("YYYY-MM-DD HH:MM:SS") than the update paths write.
412    let now = chrono::Utc::now().to_rfc3339();
413    tx.execute(
414        "INSERT INTO contracts (number, kind, issuer_id, client_id, title,
415                                effective_date, end_date, term_months,
416                                governing_law, venue, status, notes,
417                                fee_type, fee_amount_minor, fee_currency, fee_schedule,
418                                terms_json, clause_pack, clause_pack_version, default_template,
419                                created_at, updated_at)
420         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?21)",
421        params![
422            c.number,
423            c.kind,
424            c.issuer_id,
425            c.client_id,
426            c.title,
427            c.effective_date,
428            c.end_date,
429            c.term_months,
430            c.governing_law,
431            c.venue,
432            c.status,
433            c.notes,
434            c.fee_type,
435            c.fee_amount_minor,
436            c.fee_currency,
437            c.fee_schedule,
438            c.terms_json,
439            c.clause_pack,
440            c.clause_pack_version,
441            c.default_template,
442            now,
443        ],
444    )?;
445    let id = tx.last_insert_rowid();
446    for cl in clauses {
447        tx.execute(
448            "INSERT INTO contract_clauses (contract_id, position, slug, heading, body)
449             VALUES (?1, ?2, ?3, ?4, ?5)",
450            params![id, cl.position, cl.slug, cl.heading, cl.body],
451        )?;
452    }
453    tx.commit()?;
454    Ok(id)
455}
456
457pub fn contract_get(conn: &Connection, number: &str) -> Result<Contract> {
458    let sql = format!("{SELECT_CONTRACT} FROM contracts c WHERE c.number = ?1");
459    Ok(conn.query_row(&sql, params![number], row_to_contract)?)
460}
461
462pub fn contract_get_or_404(conn: &Connection, number: &str) -> Result<Contract> {
463    let sql = format!("{SELECT_CONTRACT} FROM contracts c WHERE c.number = ?1");
464    conn.query_row(&sql, params![number], row_to_contract)
465        .optional()?
466        .ok_or_else(|| AppError::NotFound(format!("contract '{number}'")))
467}
468
469pub fn contract_list(
470    conn: &Connection,
471    kind: Option<&str>,
472    status: Option<&str>,
473    issuer_slug: Option<&str>,
474) -> Result<Vec<Contract>> {
475    let mut sql = format!(
476        "{SELECT_CONTRACT} FROM contracts c JOIN issuers s ON s.id = c.issuer_id WHERE 1=1"
477    );
478    let mut p: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
479    if let Some(k) = kind {
480        sql.push_str(" AND c.kind = ?");
481        p.push(Box::new(k.to_string()));
482    }
483    if let Some(st) = status {
484        sql.push_str(" AND c.status = ?");
485        p.push(Box::new(st.to_string()));
486    }
487    if let Some(sl) = issuer_slug {
488        sql.push_str(" AND s.slug = ?");
489        p.push(Box::new(sl.to_string()));
490    }
491    sql.push_str(" ORDER BY c.effective_date DESC");
492    let mut stmt = conn.prepare(&sql)?;
493    let rows = stmt
494        .query_map(
495            rusqlite::params_from_iter(p.iter().map(|b| b.as_ref())),
496            row_to_contract,
497        )?
498        .collect::<std::result::Result<Vec<_>, _>>()?;
499    Ok(rows)
500}
501
502pub fn contract_update_draft(conn: &Connection, c: &Contract) -> Result<()> {
503    let status: Option<String> = conn
504        .query_row(
505            "SELECT status FROM contracts WHERE number = ?1",
506            params![c.number],
507            |r| r.get(0),
508        )
509        .optional()?;
510    let status = status.ok_or_else(|| AppError::NotFound(format!("contract '{}'", c.number)))?;
511    if status != "draft" {
512        return Err(AppError::InvalidInput(format!(
513            "contract '{}' is {status}, not draft — sent/signed contracts are immutable.",
514            c.number
515        )));
516    }
517    let now = chrono::Utc::now().to_rfc3339();
518    conn.execute(
519        "UPDATE contracts SET
520             client_id = ?1, title = ?2, effective_date = ?3, end_date = ?4,
521             term_months = ?5, governing_law = ?6, venue = ?7, notes = ?8,
522             fee_type = ?9, fee_amount_minor = ?10, fee_currency = ?11, fee_schedule = ?12,
523             terms_json = ?13, default_template = ?14, updated_at = ?15
524         WHERE number = ?16",
525        params![
526            c.client_id,
527            c.title,
528            c.effective_date,
529            c.end_date,
530            c.term_months,
531            c.governing_law,
532            c.venue,
533            c.notes,
534            c.fee_type,
535            c.fee_amount_minor,
536            c.fee_currency,
537            c.fee_schedule,
538            c.terms_json,
539            c.default_template,
540            now,
541            c.number,
542        ],
543    )?;
544    Ok(())
545}
546
547pub fn contract_set_status(conn: &Connection, number: &str, status: &str) -> Result<()> {
548    let valid = ["draft", "sent", "signed", "active", "expired", "terminated"];
549    if !valid.contains(&status) {
550        return Err(AppError::InvalidInput(format!(
551            "invalid status '{status}'. Expected one of: {}",
552            valid.join(", ")
553        )));
554    }
555    let now = chrono::Utc::now().to_rfc3339();
556    let affected = match status {
557        "sent" => conn.execute(
558            "UPDATE contracts SET status = ?1, sent_at = COALESCE(sent_at, ?2), updated_at = ?2 WHERE number = ?3",
559            params![status, now, number],
560        )?,
561        "signed" | "active" => conn.execute(
562            "UPDATE contracts SET status = ?1, signed_at = COALESCE(signed_at, ?2), updated_at = ?2 WHERE number = ?3",
563            params![status, now, number],
564        )?,
565        "terminated" => conn.execute(
566            "UPDATE contracts SET status = ?1, terminated_at = COALESCE(terminated_at, ?2), updated_at = ?2 WHERE number = ?3",
567            params![status, now, number],
568        )?,
569        _ => conn.execute(
570            "UPDATE contracts SET status = ?1, updated_at = ?2 WHERE number = ?3",
571            params![status, now, number],
572        )?,
573    };
574    if affected == 0 {
575        return Err(AppError::NotFound(format!("contract '{number}'")));
576    }
577    Ok(())
578}
579
580pub fn contract_record_signature(
581    conn: &Connection,
582    number: &str,
583    side: &str,
584    name: &str,
585    title: Option<&str>,
586    date_iso: &str,
587) -> Result<Contract> {
588    let now = chrono::Utc::now().to_rfc3339();
589    let affected = match side {
590        "us" => conn.execute(
591            "UPDATE contracts SET signed_by_us_name = ?1, signed_by_us_title = ?2, signed_by_us_at = ?3, updated_at = ?4 WHERE number = ?5",
592            params![name, title, date_iso, now, number],
593        )?,
594        "them" => conn.execute(
595            "UPDATE contracts SET signed_by_them_name = ?1, signed_by_them_title = ?2, signed_by_them_at = ?3, updated_at = ?4 WHERE number = ?5",
596            params![name, title, date_iso, now, number],
597        )?,
598        _ => {
599            return Err(AppError::InvalidInput(format!(
600                "side must be 'us' or 'them', got '{side}'"
601            )))
602        }
603    };
604    if affected == 0 {
605        return Err(AppError::NotFound(format!("contract '{number}'")));
606    }
607    // If both sides have signatures, auto-bump status to 'signed' (idempotent)
608    let c = contract_get(conn, number)?;
609    if c.signed_by_us_at.is_some()
610        && c.signed_by_them_at.is_some()
611        && (c.status == "draft" || c.status == "sent")
612    {
613        contract_set_status(conn, number, "signed")?;
614        return contract_get(conn, number);
615    }
616    Ok(c)
617}
618
619pub fn contract_delete(conn: &Connection, number: &str, force: bool) -> Result<()> {
620    let status: Option<String> = conn
621        .query_row(
622            "SELECT status FROM contracts WHERE number = ?1",
623            params![number],
624            |r| r.get(0),
625        )
626        .optional()?;
627    let status = status.ok_or_else(|| AppError::NotFound(format!("contract '{number}'")))?;
628    if status != "draft" && !force {
629        return Err(AppError::InvalidInput(format!(
630            "refusing to delete non-draft contract '{number}' (status='{status}') — pass --force"
631        )));
632    }
633    conn.execute("DELETE FROM contracts WHERE number = ?1", params![number])?;
634    Ok(())
635}
636
637// ─── Contract clauses CRUD ───────────────────────────────────────────────
638
639pub fn clauses_for(conn: &Connection, contract_id: i64) -> Result<Vec<ContractClauseRow>> {
640    let mut stmt = conn.prepare(
641        "SELECT id, contract_id, position, slug, heading, body
642         FROM contract_clauses WHERE contract_id = ?1 ORDER BY position",
643    )?;
644    let rows = stmt
645        .query_map(params![contract_id], |r| {
646            Ok(ContractClauseRow {
647                id: r.get(0)?,
648                contract_id: r.get(1)?,
649                position: r.get(2)?,
650                slug: r.get(3)?,
651                heading: r.get(4)?,
652                body: r.get(5)?,
653            })
654        })?
655        .collect::<std::result::Result<Vec<_>, _>>()?;
656    Ok(rows)
657}
658
659fn require_mutable(conn: &Connection, number: &str) -> Result<Contract> {
660    let c = contract_get_or_404(conn, number)?;
661    if c.status != "draft" {
662        return Err(AppError::InvalidInput(format!(
663            "contract '{number}' is {} — clauses can only be edited on draft.",
664            c.status
665        )));
666    }
667    Ok(c)
668}
669
670pub fn clause_add(
671    conn: &mut Connection,
672    number: &str,
673    slug: &str,
674    heading: Option<&str>,
675    body: Option<&str>,
676    position: Option<i64>,
677) -> Result<ContractClauseRow> {
678    let c = require_mutable(conn, number)?;
679    let tx = conn.transaction()?;
680    // disallow duplicates
681    let exists: Option<i64> = tx
682        .query_row(
683            "SELECT id FROM contract_clauses WHERE contract_id = ?1 AND slug = ?2",
684            params![c.id, slug],
685            |r| r.get(0),
686        )
687        .optional()?;
688    if exists.is_some() {
689        return Err(AppError::InvalidInput(format!(
690            "clause '{slug}' already on contract '{}'",
691            number
692        )));
693    }
694    let count: i64 = tx.query_row(
695        "SELECT COUNT(*) FROM contract_clauses WHERE contract_id = ?1",
696        params![c.id],
697        |r| r.get(0),
698    )?;
699    let insert_pos = position.unwrap_or(count).clamp(0, count);
700    // shift existing
701    tx.execute(
702        "UPDATE contract_clauses SET position = position + 1
703           WHERE contract_id = ?1 AND position >= ?2",
704        params![c.id, insert_pos],
705    )?;
706    tx.execute(
707        "INSERT INTO contract_clauses (contract_id, position, slug, heading, body)
708         VALUES (?1, ?2, ?3, ?4, ?5)",
709        params![c.id, insert_pos, slug, heading, body],
710    )?;
711    let id = tx.last_insert_rowid();
712    tx.commit()?;
713    Ok(ContractClauseRow {
714        id,
715        contract_id: c.id,
716        position: insert_pos,
717        slug: slug.to_string(),
718        heading: heading.map(str::to_string),
719        body: body.map(str::to_string),
720    })
721}
722
723pub fn clause_remove(conn: &mut Connection, number: &str, slug: &str) -> Result<()> {
724    let c = require_mutable(conn, number)?;
725    let tx = conn.transaction()?;
726    let pos: Option<i64> = tx
727        .query_row(
728            "SELECT position FROM contract_clauses WHERE contract_id = ?1 AND slug = ?2",
729            params![c.id, slug],
730            |r| r.get(0),
731        )
732        .optional()?;
733    let pos = pos.ok_or_else(|| AppError::NotFound(format!("clause '{slug}' on '{number}'")))?;
734    tx.execute(
735        "DELETE FROM contract_clauses WHERE contract_id = ?1 AND slug = ?2",
736        params![c.id, slug],
737    )?;
738    tx.execute(
739        "UPDATE contract_clauses SET position = position - 1
740           WHERE contract_id = ?1 AND position > ?2",
741        params![c.id, pos],
742    )?;
743    tx.commit()?;
744    Ok(())
745}
746
747pub fn clause_edit(
748    conn: &Connection,
749    number: &str,
750    slug: &str,
751    heading: Option<&str>,
752    body: Option<&str>,
753) -> Result<()> {
754    let c = require_mutable(conn, number)?;
755    let affected = conn.execute(
756        "UPDATE contract_clauses SET heading = COALESCE(?1, heading), body = COALESCE(?2, body)
757         WHERE contract_id = ?3 AND slug = ?4",
758        params![heading, body, c.id, slug],
759    )?;
760    if affected == 0 {
761        return Err(AppError::NotFound(format!(
762            "clause '{slug}' on contract '{number}'"
763        )));
764    }
765    Ok(())
766}
767
768pub fn clause_move(conn: &mut Connection, number: &str, slug: &str, new_pos: i64) -> Result<()> {
769    let c = require_mutable(conn, number)?;
770    let tx = conn.transaction()?;
771    let cur_pos: i64 = tx
772        .query_row(
773            "SELECT position FROM contract_clauses WHERE contract_id = ?1 AND slug = ?2",
774            params![c.id, slug],
775            |r| r.get(0),
776        )
777        .optional()?
778        .ok_or_else(|| AppError::NotFound(format!("clause '{slug}' on '{number}'")))?;
779    let count: i64 = tx.query_row(
780        "SELECT COUNT(*) FROM contract_clauses WHERE contract_id = ?1",
781        params![c.id],
782        |r| r.get(0),
783    )?;
784    let target = new_pos.clamp(0, count - 1);
785    if target == cur_pos {
786        tx.commit()?;
787        return Ok(());
788    }
789    if target > cur_pos {
790        tx.execute(
791            "UPDATE contract_clauses SET position = position - 1
792               WHERE contract_id = ?1 AND position > ?2 AND position <= ?3",
793            params![c.id, cur_pos, target],
794        )?;
795    } else {
796        tx.execute(
797            "UPDATE contract_clauses SET position = position + 1
798               WHERE contract_id = ?1 AND position >= ?2 AND position < ?3",
799            params![c.id, target, cur_pos],
800        )?;
801    }
802    tx.execute(
803        "UPDATE contract_clauses SET position = ?1 WHERE contract_id = ?2 AND slug = ?3",
804        params![target, c.id, slug],
805    )?;
806    tx.commit()?;
807    Ok(())
808}
809
810pub fn clauses_reset(
811    conn: &mut Connection,
812    number: &str,
813    fresh: &[ContractClauseRow],
814) -> Result<()> {
815    let c = require_mutable(conn, number)?;
816    let tx = conn.transaction()?;
817    tx.execute(
818        "DELETE FROM contract_clauses WHERE contract_id = ?1",
819        params![c.id],
820    )?;
821    for cl in fresh {
822        tx.execute(
823            "INSERT INTO contract_clauses (contract_id, position, slug, heading, body)
824             VALUES (?1, ?2, ?3, ?4, ?5)",
825            params![c.id, cl.position, cl.slug, cl.heading, cl.body],
826        )?;
827    }
828    tx.commit()?;
829    Ok(())
830}
831
832// ─── Numbering ────────────────────────────────────────────────────────────
833
834/// Generate the next contract number for an issuer/year/kind triple. Uses the
835/// shared `number_series` table with `kind = "contract.<kind>"` so contract
836/// sequences live separately from invoice/credit-note sequences.
837pub fn next_contract_number(
838    conn: &Connection,
839    issuer: &Issuer,
840    year: i32,
841    kind: &str,
842) -> Result<String> {
843    let series_kind = format!("contract.{kind}");
844    let seq: i64 = conn.query_row(
845        "INSERT INTO number_series (issuer_id, year, kind, next_seq)
846         VALUES (?1, ?2, ?3, 2)
847         ON CONFLICT(issuer_id, year, kind) DO UPDATE SET next_seq = next_seq + 1
848         RETURNING next_seq - 1",
849        params![issuer.id, year, series_kind],
850        |r| r.get(0),
851    )?;
852    let prefix = match kind {
853        "consulting" => "CTR",
854        "nda" => "NDA",
855        "msa" => "MSA",
856        "sow" => "SOW",
857        "service" => "SVC",
858        _ => "DOC",
859    };
860    Ok(format!("{prefix}-{}-{}-{:04}", issuer.slug, year, seq))
861}