dpp_calc/kernel/
receipt.rs1use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7use super::error::CalcError;
8
9pub use super::hashing::{input_hash, jcs_hash};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct CalculationReceipt {
26 pub receipt_id: Uuid,
28 pub input_hash: String,
30 pub output_hash: String,
33 pub ruleset_id: String,
35 pub ruleset_version: String,
37 pub bundle_version: Option<String>,
41 pub factor_dataset_id: String,
43 pub factor_dataset_version: String,
45 pub factor_set_hash: Option<String>,
48 pub computed_at: DateTime<Utc>,
50 pub jws: Option<String>,
53}
54
55impl CalculationReceipt {
56 pub fn new(
57 input_hash: impl Into<String>,
58 ruleset_id: impl Into<String>,
59 ruleset_version: impl Into<String>,
60 ) -> Self {
61 Self {
62 receipt_id: Uuid::now_v7(),
63 input_hash: input_hash.into(),
64 output_hash: String::new(),
65 ruleset_id: ruleset_id.into(),
66 ruleset_version: ruleset_version.into(),
67 bundle_version: None,
68 factor_dataset_id: String::new(),
69 factor_dataset_version: String::new(),
70 factor_set_hash: None,
71 computed_at: Utc::now(),
72 jws: None,
73 }
74 }
75
76 pub fn with_output_hash(mut self, hash: impl Into<String>) -> Self {
78 self.output_hash = hash.into();
79 self
80 }
81
82 pub fn with_bundle_version(mut self, bundle_version: impl Into<String>) -> Self {
85 self.bundle_version = Some(bundle_version.into());
86 self
87 }
88
89 pub fn with_factor_provider(mut self, provider: &dyn super::factor::FactorProvider) -> Self {
91 self.factor_dataset_id = provider.dataset_id().to_owned();
92 self.factor_dataset_version = provider.dataset_version().to_owned();
93 self.factor_set_hash = Some(provider.table_hash().to_owned());
94 self
95 }
96
97 pub fn seal_with_jws(mut self, jws: String) -> Self {
101 self.jws = Some(jws);
102 self
103 }
104
105 pub fn canonical_bytes_for_signing(&self) -> Result<Vec<u8>, CalcError> {
111 let mut v =
112 serde_json::to_value(self).map_err(|e| CalcError::CanonicalizeError(e.to_string()))?;
113 if let Some(obj) = v.as_object_mut() {
114 obj.remove("jws");
115 }
116 serde_jcs::to_vec(&v).map_err(|e| CalcError::CanonicalizeError(e.to_string()))
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use crate::factor::FactorProvider;
124
125 struct DummyProvider;
126 impl FactorProvider for DummyProvider {
127 fn dataset_id(&self) -> &str {
128 "dummy-ds"
129 }
130 fn dataset_version(&self) -> &str {
131 "1.2.3"
132 }
133 fn gwp100(&self, _activity_uuid: &str) -> Result<f64, CalcError> {
134 Ok(1.0)
135 }
136 fn table_hash(&self) -> &str {
137 "deadbeef"
138 }
139 }
140
141 #[test]
142 fn builder_records_output_factor_and_jws() {
143 let receipt = CalculationReceipt::new("in-hash", "ruleset-x", "1.0.0")
144 .with_output_hash("out-hash")
145 .with_factor_provider(&DummyProvider)
146 .seal_with_jws("jws-token".to_owned());
147
148 assert_eq!(receipt.input_hash, "in-hash");
149 assert_eq!(receipt.output_hash, "out-hash");
150 assert_eq!(receipt.ruleset_id, "ruleset-x");
151 assert_eq!(receipt.ruleset_version, "1.0.0");
152 assert_eq!(receipt.factor_dataset_id, "dummy-ds");
153 assert_eq!(receipt.factor_dataset_version, "1.2.3");
154 assert_eq!(receipt.factor_set_hash.as_deref(), Some("deadbeef"));
155 assert_eq!(receipt.jws.as_deref(), Some("jws-token"));
156 }
157
158 #[test]
159 fn bundle_version_defaults_to_none_and_round_trips() {
160 let receipt = CalculationReceipt::new("in", "r", "1.0.0");
161 assert_eq!(receipt.bundle_version, None);
162
163 let json = serde_json::to_value(&receipt).unwrap();
164 assert_eq!(json["bundle_version"], serde_json::Value::Null);
165
166 let stamped = receipt.with_bundle_version("bundle-2026.07");
167 assert_eq!(stamped.bundle_version.as_deref(), Some("bundle-2026.07"));
168 }
169
170 #[test]
171 fn canonical_bytes_exclude_the_jws_field() {
172 let sealed = CalculationReceipt::new("in", "r", "1.0.0").seal_with_jws("secret".to_owned());
173 let bytes = sealed.canonical_bytes_for_signing().unwrap();
174 let text = String::from_utf8(bytes).unwrap();
175 assert!(
176 !text.contains("secret"),
177 "jws must be excluded from the signing payload"
178 );
179 assert!(text.contains("in"));
180 }
181}