use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::clock::AssessmentClock;
use super::error::CalcError;
use super::ruleset::Ruleset;
pub use super::hashing::{input_hash, jcs_hash};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalculationReceipt {
pub receipt_id: Uuid,
pub input_hash: String,
pub output_hash: String,
pub ruleset_id: String,
pub ruleset_version: String,
pub bundle_version: Option<String>,
pub factor_dataset_id: String,
pub factor_dataset_version: String,
pub factor_set_hash: Option<String>,
pub assessed_as_of: NaiveDate,
pub computed_at: DateTime<Utc>,
pub jws: Option<String>,
}
impl CalculationReceipt {
pub fn new(
input_hash: impl Into<String>,
ruleset_id: impl Into<String>,
ruleset_version: impl Into<String>,
clock: AssessmentClock,
) -> Self {
Self {
receipt_id: Uuid::now_v7(),
input_hash: input_hash.into(),
output_hash: String::new(),
ruleset_id: ruleset_id.into(),
ruleset_version: ruleset_version.into(),
bundle_version: None,
factor_dataset_id: String::new(),
factor_dataset_version: String::new(),
factor_set_hash: None,
assessed_as_of: clock.law_in_force_on,
computed_at: clock.computed_at,
jws: None,
}
}
pub fn for_ruleset<T: Serialize>(
inputs: &T,
ruleset: &dyn Ruleset,
clock: AssessmentClock,
output_hash: impl Into<String>,
) -> Result<Self, CalcError> {
Ok(Self::new(
input_hash(inputs)?,
ruleset.id().0,
ruleset.version().0,
clock,
)
.with_output_hash(output_hash))
}
pub fn with_output_hash(mut self, hash: impl Into<String>) -> Self {
self.output_hash = hash.into();
self
}
pub fn with_bundle_version(mut self, bundle_version: impl Into<String>) -> Self {
self.bundle_version = Some(bundle_version.into());
self
}
pub fn with_factor_provider(mut self, provider: &dyn super::factor::FactorProvider) -> Self {
self.factor_dataset_id = provider.dataset_id().to_owned();
self.factor_dataset_version = provider.dataset_version().to_owned();
self.factor_set_hash = Some(provider.table_hash().to_owned());
self
}
pub fn seal_with_jws(mut self, jws: String) -> Self {
self.jws = Some(jws);
self
}
pub fn canonical_bytes_for_signing(&self) -> Result<Vec<u8>, CalcError> {
let mut v =
serde_json::to_value(self).map_err(|e| CalcError::CanonicalizeError(e.to_string()))?;
if let Some(obj) = v.as_object_mut() {
obj.remove("jws");
}
serde_jcs::to_vec(&v).map_err(|e| CalcError::CanonicalizeError(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
fn test_clock() -> AssessmentClock {
AssessmentClock::placed_on(NaiveDate::from_ymd_opt(2026, 1, 1).expect("valid date"))
}
use crate::factor::FactorProvider;
struct DummyProvider;
impl FactorProvider for DummyProvider {
fn dataset_id(&self) -> &str {
"dummy-ds"
}
fn dataset_version(&self) -> &str {
"1.2.3"
}
fn gwp100(&self, _activity_uuid: &str) -> Result<f64, CalcError> {
Ok(1.0)
}
fn table_hash(&self) -> &str {
"deadbeef"
}
}
#[test]
fn builder_records_output_factor_and_jws() {
let receipt = CalculationReceipt::new("in-hash", "ruleset-x", "1.0.0", test_clock())
.with_output_hash("out-hash")
.with_factor_provider(&DummyProvider)
.seal_with_jws("jws-token".to_owned());
assert_eq!(receipt.input_hash, "in-hash");
assert_eq!(receipt.output_hash, "out-hash");
assert_eq!(receipt.ruleset_id, "ruleset-x");
assert_eq!(receipt.ruleset_version, "1.0.0");
assert_eq!(receipt.factor_dataset_id, "dummy-ds");
assert_eq!(receipt.factor_dataset_version, "1.2.3");
assert_eq!(receipt.factor_set_hash.as_deref(), Some("deadbeef"));
assert_eq!(receipt.jws.as_deref(), Some("jws-token"));
}
#[test]
fn bundle_version_defaults_to_none_and_round_trips() {
let receipt = CalculationReceipt::new("in", "r", "1.0.0", test_clock());
assert_eq!(receipt.bundle_version, None);
let json = serde_json::to_value(&receipt).unwrap();
assert_eq!(json["bundle_version"], serde_json::Value::Null);
let stamped = receipt.with_bundle_version("bundle-2026.07");
assert_eq!(stamped.bundle_version.as_deref(), Some("bundle-2026.07"));
}
#[test]
fn canonical_bytes_exclude_the_jws_field() {
let sealed = CalculationReceipt::new("in", "r", "1.0.0", test_clock())
.seal_with_jws("secret".to_owned());
let bytes = sealed.canonical_bytes_for_signing().unwrap();
let text = String::from_utf8(bytes).unwrap();
assert!(
!text.contains("secret"),
"jws must be excluded from the signing payload"
);
assert!(text.contains("in"));
}
}