dpp_calc/kernel/
receipt.rs1use chrono::{DateTime, NaiveDate, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7use super::clock::AssessmentClock;
8use super::error::CalcError;
9use super::ruleset::Ruleset;
10
11pub use super::hashing::{input_hash, jcs_hash};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct CalculationReceipt {
28 pub receipt_id: Uuid,
30 pub input_hash: String,
32 pub output_hash: String,
35 pub ruleset_id: String,
37 pub ruleset_version: String,
39 pub bundle_version: Option<String>,
43 pub factor_dataset_id: String,
45 pub factor_dataset_version: String,
47 pub factor_set_hash: Option<String>,
50 pub assessed_as_of: NaiveDate,
58 pub computed_at: DateTime<Utc>,
60 pub jws: Option<String>,
63}
64
65impl CalculationReceipt {
66 pub fn new(
70 input_hash: impl Into<String>,
71 ruleset_id: impl Into<String>,
72 ruleset_version: impl Into<String>,
73 clock: AssessmentClock,
74 ) -> Self {
75 Self {
76 receipt_id: Uuid::now_v7(),
77 input_hash: input_hash.into(),
78 output_hash: String::new(),
79 ruleset_id: ruleset_id.into(),
80 ruleset_version: ruleset_version.into(),
81 bundle_version: None,
82 factor_dataset_id: String::new(),
83 factor_dataset_version: String::new(),
84 factor_set_hash: None,
85 assessed_as_of: clock.law_in_force_on,
86 computed_at: clock.computed_at,
87 jws: None,
88 }
89 }
90
91 pub fn for_ruleset<T: Serialize>(
97 inputs: &T,
98 ruleset: &dyn Ruleset,
99 clock: AssessmentClock,
100 output_hash: impl Into<String>,
101 ) -> Result<Self, CalcError> {
102 Ok(Self::new(
103 input_hash(inputs)?,
104 ruleset.id().0,
105 ruleset.version().0,
106 clock,
107 )
108 .with_output_hash(output_hash))
109 }
110
111 pub fn with_output_hash(mut self, hash: impl Into<String>) -> Self {
113 self.output_hash = hash.into();
114 self
115 }
116
117 pub fn with_bundle_version(mut self, bundle_version: impl Into<String>) -> Self {
120 self.bundle_version = Some(bundle_version.into());
121 self
122 }
123
124 pub fn with_factor_provider(mut self, provider: &dyn super::factor::FactorProvider) -> Self {
126 self.factor_dataset_id = provider.dataset_id().to_owned();
127 self.factor_dataset_version = provider.dataset_version().to_owned();
128 self.factor_set_hash = Some(provider.table_hash().to_owned());
129 self
130 }
131
132 pub fn seal_with_jws(mut self, jws: String) -> Self {
136 self.jws = Some(jws);
137 self
138 }
139
140 pub fn canonical_bytes_for_signing(&self) -> Result<Vec<u8>, CalcError> {
146 let mut v =
147 serde_json::to_value(self).map_err(|e| CalcError::CanonicalizeError(e.to_string()))?;
148 if let Some(obj) = v.as_object_mut() {
149 obj.remove("jws");
150 }
151 serde_jcs::to_vec(&v).map_err(|e| CalcError::CanonicalizeError(e.to_string()))
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use chrono::NaiveDate;
159
160 fn test_clock() -> AssessmentClock {
161 AssessmentClock::placed_on(NaiveDate::from_ymd_opt(2026, 1, 1).expect("valid date"))
162 }
163 use crate::factor::FactorProvider;
164
165 struct DummyProvider;
166 impl FactorProvider for DummyProvider {
167 fn dataset_id(&self) -> &str {
168 "dummy-ds"
169 }
170 fn dataset_version(&self) -> &str {
171 "1.2.3"
172 }
173 fn gwp100(&self, _activity_uuid: &str) -> Result<f64, CalcError> {
174 Ok(1.0)
175 }
176 fn table_hash(&self) -> &str {
177 "deadbeef"
178 }
179 }
180
181 #[test]
182 fn builder_records_output_factor_and_jws() {
183 let receipt = CalculationReceipt::new("in-hash", "ruleset-x", "1.0.0", test_clock())
184 .with_output_hash("out-hash")
185 .with_factor_provider(&DummyProvider)
186 .seal_with_jws("jws-token".to_owned());
187
188 assert_eq!(receipt.input_hash, "in-hash");
189 assert_eq!(receipt.output_hash, "out-hash");
190 assert_eq!(receipt.ruleset_id, "ruleset-x");
191 assert_eq!(receipt.ruleset_version, "1.0.0");
192 assert_eq!(receipt.factor_dataset_id, "dummy-ds");
193 assert_eq!(receipt.factor_dataset_version, "1.2.3");
194 assert_eq!(receipt.factor_set_hash.as_deref(), Some("deadbeef"));
195 assert_eq!(receipt.jws.as_deref(), Some("jws-token"));
196 }
197
198 #[test]
199 fn bundle_version_defaults_to_none_and_round_trips() {
200 let receipt = CalculationReceipt::new("in", "r", "1.0.0", test_clock());
201 assert_eq!(receipt.bundle_version, None);
202
203 let json = serde_json::to_value(&receipt).unwrap();
204 assert_eq!(json["bundle_version"], serde_json::Value::Null);
205
206 let stamped = receipt.with_bundle_version("bundle-2026.07");
207 assert_eq!(stamped.bundle_version.as_deref(), Some("bundle-2026.07"));
208 }
209
210 #[test]
211 fn canonical_bytes_exclude_the_jws_field() {
212 let sealed = CalculationReceipt::new("in", "r", "1.0.0", test_clock())
213 .seal_with_jws("secret".to_owned());
214 let bytes = sealed.canonical_bytes_for_signing().unwrap();
215 let text = String::from_utf8(bytes).unwrap();
216 assert!(
217 !text.contains("secret"),
218 "jws must be excluded from the signing payload"
219 );
220 assert!(text.contains("in"));
221 }
222}