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}
52
53/// One row of the rendered payment block. Parsed from `Issuer::bank_details`
54/// by splitting each non-empty line on the first ':'.
55#[derive(Debug, Clone, Serialize)]
56pub struct BankLine {
57    pub label: String,
58    pub value: String,
59}
60
61impl BankLine {
62    /// Parse the multi-line `bank_details` field into labelled rows.
63    /// Lines without a ':' get an empty label — the renderer treats them as
64    /// continuation text.
65    pub fn parse_all(details: &str) -> Vec<Self> {
66        details
67            .lines()
68            .map(str::trim)
69            .filter(|l| !l.is_empty())
70            .map(|line| match line.split_once(':') {
71                Some((label, value)) => Self {
72                    label: label.trim().to_string(),
73                    value: value.trim().to_string(),
74                },
75                None => Self {
76                    label: String::new(),
77                    value: line.to_string(),
78                },
79            })
80            .collect()
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn parses_sg_bank_details() {
90        let input = "Bank: Standard Chartered Bank (Singapore) Ltd\n\
91                     Account: 7897262250\n\
92                     Bank Code: 9496\n\
93                     Branch Code: 001\n\
94                     SWIFT: SCBLSG22";
95        let lines = BankLine::parse_all(input);
96        assert_eq!(lines.len(), 5);
97        assert_eq!(lines[0].label, "Bank");
98        assert_eq!(lines[0].value, "Standard Chartered Bank (Singapore) Ltd");
99        assert_eq!(lines[3].label, "Branch Code");
100        assert_eq!(lines[3].value, "001");
101    }
102
103    #[test]
104    fn handles_lines_without_colon() {
105        let input = "Use FAST for SG transfers\nAccount: 7897262250";
106        let lines = BankLine::parse_all(input);
107        assert_eq!(lines.len(), 2);
108        assert_eq!(lines[0].label, "");
109        assert_eq!(lines[0].value, "Use FAST for SG transfers");
110        assert_eq!(lines[1].label, "Account");
111    }
112
113    #[test]
114    fn skips_blank_lines() {
115        let input = "Bank: DBS\n\n\nAccount: 123";
116        assert_eq!(BankLine::parse_all(input).len(), 2);
117    }
118}