Skip to main content

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}