1use 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
26fn 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#[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#[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 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 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
637pub 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 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 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
832pub 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}