1use std::{fmt, str::FromStr};
6
7use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
8use sha2::{Digest, Sha256};
9
10use crate::{Date, Object, ParseError};
11
12#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub struct DocumentId(String);
15
16impl DocumentId {
17 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
52pub struct ContentHash([u8; 32]);
53
54impl ContentHash {
55 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum DocumentKind {
91 Invoice,
93 CreditNote,
95 Receipt,
97 BankStatement,
99 Acknowledgement,
101 Notice,
103 #[serde(other, skip_serializing)]
105 Unknown,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
110pub struct Original {
111 pub hash: ContentHash,
113 #[serde(deserialize_with = "crate::safe_u64")]
115 pub size: u64,
116 pub media_type: String,
118 #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::present_text")]
121 pub format: Option<String>,
122 #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::present_text")]
124 pub name: Option<String>,
125}
126
127impl Original {
128 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 pub fn is(&self, bytes: &[u8]) -> bool {
141 bytes.len() as u64 == self.size && ContentHash::of(bytes) == self.hash
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
147pub struct Document {
148 pub id: DocumentId,
150 pub kind: DocumentKind,
152 #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::present_text")]
154 pub number: Option<String>,
155 pub date: Date,
157 pub received: Date,
159 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}