Skip to main content

librotenado/
document.rs

1// SPDX-License-Identifier: MIT
2//! Documents (spec part 0005): receipts, invoices and statements as records of their own, each with the file it came
3//! as – the original – kept as an artefact.
4
5use std::{fmt, str::FromStr};
6
7use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
8use sha2::{Digest, Sha256};
9
10use crate::{Date, Object, ParseError};
11
12/// The identity of a document: a UUID in its one form (spec part 0001), like an account's. It carries no meaning.
13#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub struct DocumentId(String);
15
16impl DocumentId {
17    /// The identity as text.
18    pub fn as_str(&self) -> &str {
19        &self.0
20    }
21}
22
23impl FromStr for DocumentId {
24    type Err = ParseError;
25
26    fn from_str(s: &str) -> Result<Self, Self::Err> {
27        if crate::is_uuid(s) { Ok(DocumentId(s.to_owned())) } else { Err(ParseError::Document(s.to_owned())) }
28    }
29}
30
31impl fmt::Display for DocumentId {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        f.write_str(&self.0)
34    }
35}
36
37impl Serialize for DocumentId {
38    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
39        serializer.serialize_str(&self.0)
40    }
41}
42
43impl<'de> Deserialize<'de> for DocumentId {
44    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
45        String::deserialize(deserializer)?.parse().map_err(de::Error::custom)
46    }
47}
48
49/// The SHA-256 hash of a file's bytes: `sha256:` and 64 lower-case hexadecimal digits. The bytes themselves are
50/// stored outside the events, in any store addressed by this hash.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
52pub struct ContentHash([u8; 32]);
53
54impl ContentHash {
55    /// The hash of these bytes.
56    pub fn of(bytes: &[u8]) -> Self {
57        ContentHash(Sha256::digest(bytes).into())
58    }
59}
60
61impl FromStr for ContentHash {
62    type Err = ParseError;
63
64    fn from_str(s: &str) -> Result<Self, Self::Err> {
65        crate::parse_sha256(s).map(ContentHash).ok_or_else(|| ParseError::Hash(s.to_owned()))
66    }
67}
68
69impl fmt::Display for ContentHash {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        write!(f, "sha256:{}", hex::encode(self.0))
72    }
73}
74
75impl Serialize for ContentHash {
76    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
77        serializer.collect_str(self)
78    }
79}
80
81impl<'de> Deserialize<'de> for ContentHash {
82    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
83        String::deserialize(deserializer)?.parse().map_err(de::Error::custom)
84    }
85}
86
87/// What kind of document it is.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum DocumentKind {
91    /// An invoice.
92    Invoice,
93    /// A credit note, cancelling an invoice in whole or in part.
94    CreditNote,
95    /// A receipt: a till receipt, a ticket, a confirmation of payment.
96    Receipt,
97    /// A statement of a bank or payment account.
98    BankStatement,
99    /// An authority's confirmation that a filing was received (spec part 0007).
100    Acknowledgement,
101    /// An authority's notice or assessment, e.g. a tax assessment.
102    Notice,
103    /// A kind of a later version; kept as received, not interpreted, and not written by this version.
104    #[serde(other, skip_serializing)]
105    Unknown,
106}
107
108/// The file a document came as – an artefact: the document's data never depends on reading it.
109#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
110pub struct Original {
111    /// The hash of its bytes.
112    pub hash: ContentHash,
113    /// Its size in bytes.
114    #[serde(deserialize_with = "crate::safe_u64")]
115    pub size: u64,
116    /// Its media type, for example `application/pdf` or `application/xml`.
117    pub media_type: String,
118    /// The structured format it carries, if any, for example `en16931-cii` (also a PDF with an embedded invoice),
119    /// `en16931-ubl` or `iso20022-camt.053`. Names of formats are listed in the specification.
120    #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::present_text")]
121    pub format: Option<String>,
122    /// The file name it arrived with, if any.
123    #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::present_text")]
124    pub name: Option<String>,
125}
126
127impl Original {
128    /// Describes these bytes; the hash and size are taken from them.
129    pub fn of(bytes: &[u8], media_type: &str) -> Self {
130        Original {
131            hash: ContentHash::of(bytes),
132            size: bytes.len() as u64,
133            media_type: media_type.to_owned(),
134            format: None,
135            name: None,
136        }
137    }
138
139    /// Whether these bytes are this original: the same size and the same hash.
140    pub fn is(&self, bytes: &[u8]) -> bool {
141        bytes.len() as u64 == self.size && ContentHash::of(bytes) == self.hash
142    }
143}
144
145/// A document: what a booking is based on, as a record of its own. There is no document without its original.
146#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
147pub struct Document {
148    /// Its identity.
149    pub id: DocumentId,
150    /// What kind of document it is.
151    pub kind: DocumentKind,
152    /// Its number as printed on it (for example the invoice number), if it has one.
153    #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::present_text")]
154    pub number: Option<String>,
155    /// The date it bears (for example the invoice date).
156    pub date: Date,
157    /// The day it was received – which may be earlier than the day it was recorded, e.g. a letter scanned later.
158    pub received: Date,
159    /// The file it came as.
160    pub original: Original,
161}
162
163impl Object for Document {
164    const TYPE: &'static str = "document";
165}
166
167impl Object for Original {
168    const TYPE: &'static str = "original";
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    fn document(original: Original) -> Document {
176        Document {
177            id: "0192f1e4-0000-7000-8000-0000000000d1".parse().unwrap(),
178            kind: DocumentKind::Invoice,
179            number: Some("RE-2026-0815".into()),
180            date: "2026-10-20".parse().unwrap(),
181            received: "2026-10-24".parse().unwrap(),
182            original,
183        }
184    }
185
186    #[test]
187    fn the_original_is_an_artefact_checked_by_size_and_hash() {
188        let pdf = b"%PDF-1.7 an invoice";
189        let original = Original::of(pdf, "application/pdf");
190        assert!(original.is(pdf));
191        assert!(!original.is(b"%PDF-1.7 an invoice, changed"));
192        let written = original.hash.to_string();
193        assert!(written.starts_with("sha256:") && written.len() == 71);
194        assert_eq!(written.parse::<ContentHash>().unwrap(), original.hash);
195        assert!("sha1:00".parse::<ContentHash>().is_err());
196    }
197
198    #[test]
199    fn the_data_stands_on_its_own() {
200        let doc = document(Original::of(b"x", "text/plain"));
201        let json = serde_json::to_string(&doc).unwrap();
202        assert!(json.contains(r#""kind":"invoice""#) && json.contains(r#""number":"RE-2026-0815""#), "{json}");
203        assert!(!json.contains("format") && !json.contains(r#""name""#));
204        assert_eq!(serde_json::from_str::<Document>(&json).unwrap(), doc);
205        let empty = json.replace("RE-2026-0815", "");
206        assert!(serde_json::from_str::<Document>(&empty).is_err(), "an empty number is left out");
207    }
208}