finance_core/entity.rs
1// ═══════════════════════════════════════════════════════════════════════════
2// Entity — the canonical "company" primitive shared across the suite.
3//
4// Historical name is `Issuer` (a company that ISSUES invoices); we keep the
5// type name `Issuer` for now since invoice-cli is the only consumer and
6// renaming every call-site adds churn for no gain. Later tools (receipt-cli
7// for merchants, ledger-cli for counterparties) will query the same table.
8//
9// `bank_details` is a free-form multi-line string with one "Label: Value" per
10// line. This handles every country without per-jurisdiction schema (SG bank
11// code / UK sort code / US ABA routing / EU IBAN / AU BSB …).
12// ═══════════════════════════════════════════════════════════════════════════
13
14use serde::{Deserialize, Serialize};
15
16use crate::tax::Jurisdiction;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct Issuer {
20 pub id: i64,
21 pub slug: String,
22 pub name: String,
23 pub legal_name: Option<String>,
24 pub jurisdiction: Jurisdiction,
25 pub tax_registered: bool,
26 pub tax_id: Option<String>,
27 pub company_no: Option<String>,
28 pub tagline: Option<String>,
29 pub address: Vec<String>,
30 pub email: Option<String>,
31 pub phone: Option<String>,
32 /// Free-form multi-line bank / payment details. One `Label: Value` per
33 /// line. Renderer splits on the first ":" per line and displays a
34 /// two-column table on the PDF. Example:
35 ///
36 /// ```text
37 /// Bank: Standard Chartered Bank (Singapore) Ltd
38 /// Account: 7897262250
39 /// Bank Code: 9496
40 /// Branch Code: 001
41 /// SWIFT: SCBLSG22
42 /// ```
43 pub bank_details: Option<String>,
44 pub default_template: String,
45 pub currency: Option<String>,
46 pub symbol: Option<String>,
47 pub number_format: String,
48 /// Filesystem path to a logo image (PNG/SVG/JPG). Rendered in template
49 /// header when set.
50 pub logo_path: Option<String>,
51 /// Default directory for `invoices render` output when `--out` is
52 /// omitted. `~/` is expanded at read time by the consumer CLI.
53 pub default_output_dir: Option<String>,
54 /// Default notes auto-populated into new invoices when `--notes` is
55 /// omitted. Free-form multi-line text (payment terms, reverse-charge
56 /// disclaimers, bank-fee notices, …).
57 pub default_notes: Option<String>,
58}
59
60/// One row of the rendered payment block. Parsed from `Issuer::bank_details`
61/// by splitting each non-empty line on the first ':'.
62#[derive(Debug, Clone, Serialize)]
63pub struct BankLine {
64 pub label: String,
65 pub value: String,
66}
67
68impl BankLine {
69 /// Parse the multi-line `bank_details` field into labelled rows.
70 /// Lines without a ':' get an empty label — the renderer treats them as
71 /// continuation text.
72 pub fn parse_all(details: &str) -> Vec<Self> {
73 details
74 .lines()
75 .map(str::trim)
76 .filter(|l| !l.is_empty())
77 .map(|line| match line.split_once(':') {
78 Some((label, value)) => Self {
79 label: label.trim().to_string(),
80 value: value.trim().to_string(),
81 },
82 None => Self {
83 label: String::new(),
84 value: line.to_string(),
85 },
86 })
87 .collect()
88 }
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94
95 #[test]
96 fn parses_sg_bank_details() {
97 let input = "Bank: Standard Chartered Bank (Singapore) Ltd\n\
98 Account: 7897262250\n\
99 Bank Code: 9496\n\
100 Branch Code: 001\n\
101 SWIFT: SCBLSG22";
102 let lines = BankLine::parse_all(input);
103 assert_eq!(lines.len(), 5);
104 assert_eq!(lines[0].label, "Bank");
105 assert_eq!(lines[0].value, "Standard Chartered Bank (Singapore) Ltd");
106 assert_eq!(lines[3].label, "Branch Code");
107 assert_eq!(lines[3].value, "001");
108 }
109
110 #[test]
111 fn handles_lines_without_colon() {
112 let input = "Use FAST for SG transfers\nAccount: 7897262250";
113 let lines = BankLine::parse_all(input);
114 assert_eq!(lines.len(), 2);
115 assert_eq!(lines[0].label, "");
116 assert_eq!(lines[0].value, "Use FAST for SG transfers");
117 assert_eq!(lines[1].label, "Account");
118 }
119
120 #[test]
121 fn skips_blank_lines() {
122 let input = "Bank: DBS\n\n\nAccount: 123";
123 assert_eq!(BankLine::parse_all(input).len(), 2);
124 }
125}